aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCody Robibero <cody@robibe.ro>2024-02-28 17:09:23 -0700
committerGitHub <noreply@github.com>2024-02-28 17:09:23 -0700
commitf3c333f4d5bb272b5ffcff29af337ca31e8c374b (patch)
tree008525e157be39a25e013fd3b039d4680760eb68
parent54eb81395ef8d3d4cb064b56361ce94fc72b38b5 (diff)
parent4f0f364ac941dc4a856512c9bf0e6b93fdf7b3ab (diff)
Merge branch 'master' into bhowe34/fix-replace-missing-metadata-for-music
-rw-r--r--.config/dotnet-tools.json2
-rw-r--r--.devcontainer/Dev - Server Ffmpeg/devcontainer.json28
-rw-r--r--.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh32
-rw-r--r--.github/ISSUE_TEMPLATE/issue report.yml49
-rw-r--r--.github/ISSUE_TEMPLATE/media_playback.md34
-rw-r--r--.github/workflows/ci-codeql-analysis.yml6
-rw-r--r--.github/workflows/ci-openapi.yml14
-rw-r--r--.github/workflows/ci-tests.yml2
-rw-r--r--.github/workflows/commands.yml10
-rw-r--r--.vscode/extensions.json2
-rw-r--r--.vscode/launch.json12
-rw-r--r--CONTRIBUTORS.md5
-rw-r--r--Directory.Packages.props41
-rw-r--r--Emby.Naming/Common/NamingOptions.cs12
-rw-r--r--Emby.Naming/ExternalFiles/ExternalPathParser.cs3
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs37
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs21
-rw-r--r--Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs45
-rw-r--r--Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs88
-rw-r--r--Emby.Server.Implementations/IO/LibraryMonitor.cs71
-rw-r--r--Emby.Server.Implementations/IO/LibraryMonitorStartup.cs35
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs32
-rw-r--r--Emby.Server.Implementations/Library/LiveStreamHelper.cs17
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs21
-rw-r--r--Emby.Server.Implementations/Localization/Core/et.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/ga.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ky.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/mk.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ms.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/or.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/sl-SI.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/vi.json4
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs3
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs1
-rw-r--r--Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs25
-rw-r--r--Jellyfin.Api/Controllers/DisplayPreferencesController.cs15
-rw-r--r--Jellyfin.Api/Controllers/DynamicHlsController.cs133
-rw-r--r--Jellyfin.Api/Controllers/InstantMixController.cs40
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs5
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs8
-rw-r--r--Jellyfin.Api/Controllers/LiveTvController.cs67
-rw-r--r--Jellyfin.Api/Controllers/LyricsController.cs267
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs3
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs7
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs46
-rw-r--r--Jellyfin.Api/Controllers/SystemController.cs12
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs45
-rw-r--r--Jellyfin.Api/Controllers/VideosController.cs6
-rw-r--r--Jellyfin.Api/Helpers/DynamicHlsHelper.cs25
-rw-r--r--Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs8
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs21
-rw-r--r--Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs2
-rw-r--r--Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs13
-rw-r--r--Jellyfin.Data/Entities/User.cs1
-rw-r--r--Jellyfin.Data/Enums/PermissionKind.cs7
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs101
-rw-r--r--Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs2
-rw-r--r--Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj1
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs143
-rw-r--r--Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs64
-rw-r--r--Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs76
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs1
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs2
-rw-r--r--Jellyfin.Server/Jellyfin.Server.csproj5
-rw-r--r--Jellyfin.Server/Jellyfin.Server.icobin0 -> 40883 bytes
-rw-r--r--Jellyfin.Server/Startup.cs9
-rw-r--r--MediaBrowser.Common/Api/Policies.cs5
-rw-r--r--MediaBrowser.Common/Progress/ActionableProgress.cs37
-rw-r--r--MediaBrowser.Common/Progress/SimpleProgress.cs17
-rw-r--r--MediaBrowser.Controller/Channels/Channel.cs3
-rw-r--r--MediaBrowser.Controller/Drawing/ImageStream.cs42
-rw-r--r--MediaBrowser.Controller/Entities/Audio/Audio.cs12
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs35
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs4
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs9
-rw-r--r--MediaBrowser.Controller/Library/ILibraryMonitor.cs9
-rw-r--r--MediaBrowser.Controller/LiveTv/IGuideManager.cs26
-rw-r--r--MediaBrowser.Controller/LiveTv/IListingsManager.cs79
-rw-r--r--MediaBrowser.Controller/LiveTv/ILiveTvManager.cs67
-rw-r--r--MediaBrowser.Controller/LiveTv/IRecordingsManager.cs55
-rw-r--r--MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs17
-rw-r--r--MediaBrowser.Controller/Lyrics/ILyricManager.cs100
-rw-r--r--MediaBrowser.Controller/Lyrics/ILyricParser.cs4
-rw-r--r--MediaBrowser.Controller/Lyrics/ILyricProvider.cs34
-rw-r--r--MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs26
-rw-r--r--MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs6
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs589
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs20
-rw-r--r--MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs7
-rw-r--r--MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs23
-rw-r--r--MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs11
-rw-r--r--MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs9
-rw-r--r--MediaBrowser.Controller/Plugins/IServerEntryPoint.cs20
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs46
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs21
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs65
-rw-r--r--MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj3
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs23
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs280
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs59
-rw-r--r--MediaBrowser.Model/ClientLog/ClientLogEvent.cs75
-rw-r--r--MediaBrowser.Model/Configuration/LibraryOptions.cs5
-rw-r--r--MediaBrowser.Model/Configuration/MetadataPluginType.cs3
-rw-r--r--MediaBrowser.Model/Dlna/DlnaProfileType.cs3
-rw-r--r--MediaBrowser.Model/Dlna/StreamBuilder.cs32
-rw-r--r--MediaBrowser.Model/Dto/ImageByNameInfo.cs38
-rw-r--r--MediaBrowser.Model/Entities/MediaStreamType.cs7
-rw-r--r--MediaBrowser.Model/Entities/SpecialFolder.cs36
-rw-r--r--MediaBrowser.Model/LiveTv/ChannelMappingOptionsDto.cs (renamed from Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs)3
-rw-r--r--MediaBrowser.Model/LiveTv/TunerChannelMapping.cs16
-rw-r--r--MediaBrowser.Model/Lyrics/LyricDto.cs (renamed from MediaBrowser.Controller/Lyrics/LyricResponse.cs)7
-rw-r--r--MediaBrowser.Model/Lyrics/LyricFile.cs (renamed from MediaBrowser.Controller/Lyrics/LyricFile.cs)2
-rw-r--r--MediaBrowser.Model/Lyrics/LyricLine.cs (renamed from MediaBrowser.Controller/Lyrics/LyricLine.cs)2
-rw-r--r--MediaBrowser.Model/Lyrics/LyricMetadata.cs (renamed from MediaBrowser.Controller/Lyrics/LyricMetadata.cs)7
-rw-r--r--MediaBrowser.Model/Lyrics/LyricResponse.cs19
-rw-r--r--MediaBrowser.Model/Lyrics/LyricSearchRequest.cs59
-rw-r--r--MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs22
-rw-r--r--MediaBrowser.Model/Lyrics/UploadLyricDto.cs16
-rw-r--r--MediaBrowser.Model/Net/MimeTypes.cs13
-rw-r--r--MediaBrowser.Model/Net/SocketReceiveResult.cs32
-rw-r--r--MediaBrowser.Model/Providers/LyricProviderInfo.cs17
-rw-r--r--MediaBrowser.Model/Providers/RemoteLyricInfo.cs29
-rw-r--r--MediaBrowser.Model/Session/ClientCapabilities.cs11
-rw-r--r--MediaBrowser.Model/Session/GeneralCommandType.cs3
-rw-r--r--MediaBrowser.Model/Session/HardwareEncodingType.cs7
-rw-r--r--MediaBrowser.Model/Session/PlaybackOrder.cs18
-rw-r--r--MediaBrowser.Model/Session/PlaybackProgressInfo.cs6
-rw-r--r--MediaBrowser.Model/Session/PlayerStateInfo.cs6
-rw-r--r--MediaBrowser.Model/System/SystemInfo.cs29
-rw-r--r--MediaBrowser.Model/Users/UserPolicy.cs6
-rw-r--r--MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs69
-rw-r--r--MediaBrowser.Providers/Lyric/ILyricProvider.cs36
-rw-r--r--MediaBrowser.Providers/Lyric/LrcLyricParser.cs15
-rw-r--r--MediaBrowser.Providers/Lyric/LyricManager.cs428
-rw-r--r--MediaBrowser.Providers/Lyric/TxtLyricParser.cs11
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs25
-rw-r--r--MediaBrowser.Providers/MediaInfo/AudioFileProber.cs31
-rw-r--r--MediaBrowser.Providers/MediaInfo/LyricResolver.cs39
-rw-r--r--MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs97
-rw-r--r--MediaBrowser.Providers/MediaInfo/ProbeProvider.cs50
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs2
-rw-r--r--MediaBrowser.XbmcMetadata/EntryPoint.cs78
-rw-r--r--MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs87
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs26
-rw-r--r--README.md27
-rw-r--r--deployment/Dockerfile.centos.amd642
-rw-r--r--deployment/Dockerfile.fedora.amd642
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaCodecException.cs44
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaEncoder.cs1
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaException.cs38
-rw-r--r--src/Jellyfin.Drawing/ImageProcessor.cs16
-rw-r--r--src/Jellyfin.Drawing/Jellyfin.Drawing.csproj4
-rw-r--r--src/Jellyfin.Extensions/StringExtensions.cs10
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelManager.cs18
-rw-r--r--src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs3
-rw-r--r--src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs9
-rw-r--r--src/Jellyfin.LiveTv/DefaultLiveTvService.cs998
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs2537
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs21
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs19
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs24
-rw-r--r--src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs15
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs711
-rw-r--r--src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs74
-rw-r--r--src/Jellyfin.LiveTv/IO/DirectRecorder.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs)2
-rw-r--r--src/Jellyfin.LiveTv/IO/EncodedRecorder.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs)2
-rw-r--r--src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs (renamed from src/Jellyfin.LiveTv/ExclusiveLiveStream.cs)2
-rw-r--r--src/Jellyfin.LiveTv/IO/IRecorder.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs)2
-rw-r--r--src/Jellyfin.LiveTv/IO/StreamHelper.cs (renamed from src/Jellyfin.LiveTv/StreamHelper.cs)2
-rw-r--r--src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj3
-rw-r--r--src/Jellyfin.LiveTv/Listings/EpgChannelData.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs)2
-rw-r--r--src/Jellyfin.LiveTv/Listings/ListingsManager.cs461
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs115
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs1089
-rw-r--r--src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs220
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs)9
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs (renamed from src/Jellyfin.LiveTv/RecordingNotifier.cs)75
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs37
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs837
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs501
-rw-r--r--src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs70
-rw-r--r--src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs)2
-rw-r--r--src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs29
-rw-r--r--src/Jellyfin.LiveTv/Timers/TimerManager.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs)37
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs1
-rw-r--r--src/Jellyfin.Networking/PortForwardingHost.cs (renamed from src/Jellyfin.Networking/ExternalPortForwarding.cs)71
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs35
-rw-r--r--tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs2
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs17
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_webm.json106
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs10
-rw-r--r--tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs13
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json2
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json2
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json2
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs4
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs2
199 files changed, 7711 insertions, 5736 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 81fe5add4..d9b689bb6 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "8.0.1",
+ "version": "8.0.2",
"commands": [
"dotnet-ef"
]
diff --git a/.devcontainer/Dev - Server Ffmpeg/devcontainer.json b/.devcontainer/Dev - Server Ffmpeg/devcontainer.json
new file mode 100644
index 000000000..0b848d9f3
--- /dev/null
+++ b/.devcontainer/Dev - Server Ffmpeg/devcontainer.json
@@ -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
+ }
+}
diff --git a/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh b/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh
new file mode 100644
index 000000000..c867ef538
--- /dev/null
+++ b/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh
@@ -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
diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml
index 587802833..b690b82c2 100644
--- a/.github/ISSUE_TEMPLATE/issue report.yml
+++ b/.github/ISSUE_TEMPLATE/issue report.yml
@@ -6,7 +6,11 @@ body:
- type: markdown
attributes:
value: |
- Thanks for taking the time to fill out this bug report! Please provide as much detail as necessary, most questions may not be applicable to you. If you need real-time help, join us on [Matrix](https://matrix.to/#/#jellyfin-troubleshooting:matrix.org) or [Discord](https://discord.gg/zHBxVSXdBV).
+ Thanks for taking the time to report an issue. Before submitting a report, please do the following:
+ 1. Please head to our forum or chat rooms and troubleshoot with volunteers if you haven't already. Links can be found here: https://jellyfin.org/contact/
+ 2. Please search the bug tracker for similar issues. If you do find one, please comment there instead of opening a new bug report.
+ 3. If you decide to open a new report, please provide as much detail as possible.
+ 4. Please **ONLY** report **ONE** issue per report. If you are experiencing multiple issues, please open multiple reports.
- type: textarea
id: what-happened
attributes:
@@ -14,14 +18,18 @@ body:
description: Also tell us, what did you expect to happen?
placeholder: |
The more information that you are able to provide, the better. Did you do anything before this happened? Did you upgrade or change anything? Any screenshots or logs you can provide will be helpful.
-
- This is my issue.
-
- Steps to Reproduce
- 1. In this environment...
- 2. With this config...
- 3. Run '...'
- 4. See error...
+ If you are using an old release of Jellyfin, please also explain why.
+ validations:
+ required: true
+ - type: textarea
+ id: repro-steps
+ attributes:
+ label: Reproduction Steps
+ placeholder: |
+ 1. In this environment...
+ 2. With this config...
+ 3. Run '...'
+ 4. See error...
validations:
required: true
- type: dropdown
@@ -30,11 +38,10 @@ body:
label: Jellyfin Version
description: What version of Jellyfin are you running?
options:
- - 10.8.z
- - 10.8.9
- - 10.7.7
- - 10.6.4
- - Other
+ - 10.8.13
+ - 10.8.12
+ - 10.8.11 or older (please specify)
+ - Unstable (master branch)
validations:
required: true
- type: input
@@ -77,6 +84,18 @@ body:
- Networking:
- Storage:
render: markdown
+ validations:
+ required: true
+ - type: markdown
+ attributes:
+ value: |
+ When providing logs, please keep the following things in mind.
+ 1. **DO NOT** use external paste services.
+ 2. Please provide complete logs.
+ - For server logs, include everything you think is important plus *10 lines before and after*
+ - For ffmpeg logs, please provide the entire file unmodified.
+ 3. Please do not run logs through any translation program. Especially beware if your browser translates pages by default.
+ 4. Please do not include logs as screenshots, with the only exception being client logs in browsers.
- type: textarea
id: logs
attributes:
@@ -84,6 +103,8 @@ body:
description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs.
placeholder: For playback issues, browser/client and FFmpeg logs may be more useful.
render: shell
+ validations:
+ required: true
- type: textarea
id: ffmpeg-logs
attributes:
diff --git a/.github/ISSUE_TEMPLATE/media_playback.md b/.github/ISSUE_TEMPLATE/media_playback.md
deleted file mode 100644
index b51500f87..000000000
--- a/.github/ISSUE_TEMPLATE/media_playback.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-name: Media playback issue
-about: Create a media playback issue report
-title: ''
-labels: mediaplayback
-assignees: ''
-
----
-
-**Media Info of the file**
-<!-- Use the Media Info tool (set to text format, download here: https://mediaarea.net/en/MediaInfo) or copy the info from the web ui for the file with the playback issue. -->
-
-**Logs**
-<!-- Please paste any log messages from during the playback issue. -->
-
-**FFmpeg Logs**
-<!-- Please paste any FFmpeg logs if remuxing or transcoding appears to be part of the issue. -->
-
-**Stats for Nerds Screenshots**
-<!-- If available, add screenshots of the stats for nerds screen to help show the issue problem. -->
-
-**Server System (please complete the following information):**
- - OS: [e.g. Docker on Linux, Docker on Windows, Debian, Windows]
- - Jellyfin Version: [e.g. 10.0.1]
- - Hardware settings & device: [e.g. NVENC on GTX1060, VAAPI on Intel i7 8700K]
- - Reverse proxy: [e.g. no, nginx, apache, etc.]
- - Other hardware notes: [e.g. Media mounted in CIFS/SMB share, Media mounted from Google Drive]
-
-**Client System (please complete the following information):**
- - Device: [e.g. Apple iPhone XS, Xbox One S, LG OLED55C8, Samsung Galaxy Note9, Custom HTPC]
- - OS: [e.g. iOS, Android, Windows, macOS]
- - Client: [e.g. Web/Browser, webOS, Android, Android TV, Electron]
- - Browser (if Web client): [e.g. Firefox, Chrome, Safari]
- - Client and Browser Version: [e.g. 10.3.4 and 68.0]
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index d8c550e70..6e2da9737 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -27,11 +27,11 @@ jobs:
dotnet-version: '8.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1
+ uses: github/codeql-action/init@47b3d888fe66b639e431abf22ebca059152f1eea # v3.24.5
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1
+ uses: github/codeql-action/autobuild@47b3d888fe66b639e431abf22ebca059152f1eea # v3.24.5
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1
+ uses: github/codeql-action/analyze@47b3d888fe66b639e431abf22ebca059152f1eea # v3.24.5
diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml
index e43160562..c56349941 100644
--- a/.github/workflows/ci-openapi.yml
+++ b/.github/workflows/ci-openapi.yml
@@ -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@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.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@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.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@87c55149d96e628cc2ef7e6fc2aab372015aec85 # v4.1.3
with:
name: openapi-head
path: openapi-head
- name: Download openapi-base
- uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1
+ uses: actions/download-artifact@87c55149d96e628cc2ef7e6fc2aab372015aec85 # v4.1.3
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 }}
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 0dacbc5c6..8ee6b3028 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -34,7 +34,7 @@ jobs:
--verbosity minimal
- name: Merge code coverage results
- uses: danielpalme/ReportGenerator-GitHub-Action@4d510cbed8a05af5aefea46c7fd6e05b95844c89 # 5.2.0
+ uses: danielpalme/ReportGenerator-GitHub-Action@b067e0c5d288fb4277b9f397b2dc6013f60381f0 # 5.2.2
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged/"
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index 75b6a73e5..386f8d321 100644
--- a/.github/workflows/commands.yml
+++ b/.github/workflows/commands.yml
@@ -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 }}
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index d738e9fba..3be946e44 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -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"
],
diff --git a/.vscode/launch.json b/.vscode/launch.json
index be55764fd..7e50d4f0a 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -30,6 +30,18 @@
"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",
"request": "attach",
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 457f59e0f..55642e4e2 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -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)
@@ -175,7 +177,9 @@
- [Chris-Codes-It](https://github.com/Chris-Codes-It)
- [Pithaya](https://github.com/Pithaya)
- [Çağrı Sakaoğlu](https://github.com/ilovepilav)
+ _ [Barasingha](https://github.com/MaVdbussche)
- [Gauvino](https://github.com/Gauvino)
+ - [felix920506](https://github.com/felix920506)
# Emby Contributors
@@ -247,3 +251,4 @@
- [Utku Özdemir](https://github.com/utkuozdemir)
- [JPUC1143](https://github.com/Jpuc1143/)
- [0x25CBFC4F](https://github.com/0x25CBFC4F)
+ - [Robert Lützner](https://github.com/rluetzner)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index dcf183494..1d7ebfaf4 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,34 +4,35 @@
</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="coverlet.collector" Version="6.0.1" />
<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.2.0" />
+ <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.2.2" />
<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" />
+ <PackageVersion Include="IDisposableAnalyzers" Version="4.0.7" />
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
<PackageVersion Include="libse" Version="3.6.13" />
<PackageVersion Include="LrcParser" Version="2023.524.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
- <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.1" />
+ <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" />
- <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.1" />
+ <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
- <PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.1" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.1" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.1" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.1" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.1" />
+ <PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.2" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.2" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.2" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.2" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.2" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
@@ -40,14 +41,14 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.1" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.1" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.2" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.2" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.0" />
<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.Extensions.Options" Version="8.0.2" />
+ <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,20 +72,20 @@
<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" />
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="8.0.0" />
- <PackageVersion Include="System.Text.Json" Version="8.0.1" />
+ <PackageVersion Include="System.Text.Json" Version="8.0.2" />
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="8.0.0" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="TMDbLib" Version="2.1.0" />
<PackageVersion Include="UTF.Unknown" Version="2.5.1" />
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
- <PackageVersion Include="xunit.runner.visualstudio" Version="2.5.6" />
+ <PackageVersion Include="xunit.runner.visualstudio" Version="2.5.7" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" />
- <PackageVersion Include="xunit" Version="2.6.6" />
+ <PackageVersion Include="xunit" Version="2.7.0" />
</ItemGroup>
</Project>
diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs
index b63c8f10e..4bd226d95 100644
--- a/Emby.Naming/Common/NamingOptions.cs
+++ b/Emby.Naming/Common/NamingOptions.cs
@@ -173,6 +173,13 @@ namespace Emby.Naming.Common
".vtt",
};
+ LyricFileExtensions = new[]
+ {
+ ".lrc",
+ ".elrc",
+ ".txt"
+ };
+
AlbumStackingPrefixes = new[]
{
"cd",
@@ -792,6 +799,11 @@ namespace Emby.Naming.Common
public string[] SubtitleFileExtensions { get; set; }
/// <summary>
+ /// Gets the list of lyric file extensions.
+ /// </summary>
+ public string[] LyricFileExtensions { get; }
+
+ /// <summary>
/// Gets or sets list of episode regular expressions.
/// </summary>
public EpisodeExpression[] EpisodeExpressions { get; set; }
diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs
index 4080ba10d..9d54533c2 100644
--- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs
+++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs
@@ -45,7 +45,8 @@ namespace Emby.Naming.ExternalFiles
var extension = Path.GetExtension(path.AsSpan());
if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
- && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
+ && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
+ && !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
{
return null;
}
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index 5870fed76..745753440 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -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/>
@@ -659,7 +630,7 @@ namespace Emby.Server.Implementations
BaseItem.FileSystem = Resolve<IFileSystem>();
BaseItem.UserDataManager = Resolve<IUserDataManager>();
BaseItem.ChannelManager = Resolve<IChannelManager>();
- Video.LiveTvManager = Resolve<ILiveTvManager>();
+ Video.RecordingsManager = Resolve<IRecordingsManager>();
Folder.UserViewManager = Resolve<IUserViewManager>();
UserView.TVSeriesManager = Resolve<ITVSeriesManager>();
UserView.CollectionManager = Resolve<ICollectionManager>();
@@ -695,8 +666,6 @@ namespace Emby.Server.Implementations
GetExports<IMetadataSaver>(),
GetExports<IExternalId>());
- Resolve<ILiveTvManager>().AddParts(GetExports<ILiveTvService>(), GetExports<IListingsProvider>());
-
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
}
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index d0d5bb81c..7812687ea 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -18,7 +18,6 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
-using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
@@ -47,12 +46,12 @@ namespace Emby.Server.Implementations.Dto
private readonly IImageProcessor _imageProcessor;
private readonly IProviderManager _providerManager;
+ private readonly IRecordingsManager _recordingsManager;
private readonly IApplicationHost _appHost;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly Lazy<ILiveTvManager> _livetvManagerFactory;
- private readonly ILyricManager _lyricManager;
private readonly ITrickplayManager _trickplayManager;
public DtoService(
@@ -62,10 +61,10 @@ namespace Emby.Server.Implementations.Dto
IItemRepository itemRepo,
IImageProcessor imageProcessor,
IProviderManager providerManager,
+ IRecordingsManager recordingsManager,
IApplicationHost appHost,
IMediaSourceManager mediaSourceManager,
Lazy<ILiveTvManager> livetvManagerFactory,
- ILyricManager lyricManager,
ITrickplayManager trickplayManager)
{
_logger = logger;
@@ -74,10 +73,10 @@ namespace Emby.Server.Implementations.Dto
_itemRepo = itemRepo;
_imageProcessor = imageProcessor;
_providerManager = providerManager;
+ _recordingsManager = recordingsManager;
_appHost = appHost;
_mediaSourceManager = mediaSourceManager;
_livetvManagerFactory = livetvManagerFactory;
- _lyricManager = lyricManager;
_trickplayManager = trickplayManager;
}
@@ -149,10 +148,6 @@ namespace Emby.Server.Implementations.Dto
{
LivetvManager.AddInfoToProgramDto(new[] { (item, dto) }, options.Fields, user).GetAwaiter().GetResult();
}
- else if (item is Audio)
- {
- dto.HasLyrics = _lyricManager.HasLyricFile(item);
- }
if (item is IItemByName itemByName
&& options.ContainsField(ItemFields.ItemCounts))
@@ -256,8 +251,7 @@ namespace Emby.Server.Implementations.Dto
dto.Etag = item.GetEtag(user);
}
- var liveTvManager = LivetvManager;
- var activeRecording = liveTvManager.GetActiveRecordingInfo(item.Path);
+ var activeRecording = _recordingsManager.GetActiveRecordingInfo(item.Path);
if (activeRecording is not null)
{
dto.Type = BaseItemKind.Recording;
@@ -270,7 +264,12 @@ namespace Emby.Server.Implementations.Dto
dto.Name = dto.SeriesName;
}
- liveTvManager.AddInfoToRecordingDto(item, dto, activeRecording, user);
+ LivetvManager.AddInfoToRecordingDto(item, dto, activeRecording, user);
+ }
+
+ if (item is Audio audio)
+ {
+ dto.HasLyrics = audio.GetMediaStreams().Any(s => s.Type == MediaStreamType.Lyric);
}
return dto;
diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
index 83e7b230d..4c668379c 100644
--- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
+++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
@@ -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;
}
}
diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
index d32759017..957ad9c01 100644
--- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
+++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
@@ -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;
}
}
}
diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs
index dde38906f..31617d1a5 100644
--- a/Emby.Server.Implementations/IO/LibraryMonitor.cs
+++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs
@@ -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;
- }
- /// <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;
+ appLifetime.ApplicationStarted.Register(Start);
}
+ /// <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,21 +299,12 @@ 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>
/// <param name="sender">The source of the event.</param>
@@ -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;
}
}
diff --git a/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs b/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs
deleted file mode 100644
index c51cf0545..000000000
--- a/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs
+++ /dev/null
@@ -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()
- {
- }
- }
-}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 8ae913dad..13a381060 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -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;
@@ -1239,6 +1232,19 @@ namespace Emby.Server.Implementations.Library
return item;
}
+ /// <inheritdoc />
+ public T GetItemById<T>(Guid id)
+ where T : BaseItem
+ {
+ var item = GetItemById(id);
+ if (item is T typedItem)
+ {
+ return typedItem;
+ }
+
+ return null;
+ }
+
public List<BaseItem> GetItemList(InternalItemsQuery query, bool allowExternalContent)
{
if (query.Recursive && !query.ParentId.IsEmpty())
@@ -2954,7 +2960,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);
});
}
diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs
index 59d705ace..d4aeae41a 100644
--- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs
+++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs
@@ -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 (Exception ex)
+ catch (IOException ex)
{
- _logger.LogError(ex, "Error deserializing mediainfo cache");
+ _logger.LogDebug(ex, "Could not open cached media info");
}
- finally
+ catch (Exception ex)
{
- await jsonStream.DisposeAsync().ConfigureAwait(false);
+ _logger.LogError(ex, "Error opening cached media info");
}
}
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index c38f1af91..18ada6aeb 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -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();
}
}
}
diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json
index 081462407..977307b06 100644
--- a/Emby.Server.Implementations/Localization/Core/et.json
+++ b/Emby.Server.Implementations/Localization/Core/et.json
@@ -31,7 +31,7 @@
"VersionNumber": "Versioon {0}",
"ValueSpecialEpisodeName": "Eriepisood - {0}",
"ValueHasBeenAddedToLibrary": "{0} lisati meediakogusse",
- "UserStartedPlayingItemWithValues": "{0} taasesitab {1} serveris {2}",
+ "UserStartedPlayingItemWithValues": "{0} taasesitab {1} seadmes {2}",
"UserPasswordChangedWithName": "Kasutaja {0} parool muudeti",
"UserLockedOutWithName": "Kasutaja {0} lukustati",
"UserDeletedWithName": "Kasutaja {0} kustutati",
@@ -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."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json
new file mode 100644
index 000000000..28e54bff5
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/ga.json
@@ -0,0 +1,3 @@
+{
+ "Albums": "Albaim"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/ky.json b/Emby.Server.Implementations/Localization/Core/ky.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/ky.json
@@ -0,0 +1 @@
+{}
diff --git a/Emby.Server.Implementations/Localization/Core/mk.json b/Emby.Server.Implementations/Localization/Core/mk.json
index cbccad87f..7ef907918 100644
--- a/Emby.Server.Implementations/Localization/Core/mk.json
+++ b/Emby.Server.Implementations/Localization/Core/mk.json
@@ -122,5 +122,6 @@
"TaskRefreshChapterImagesDescription": "Создава тамбнеил за видеата шти имаат поглавја.",
"TaskCleanActivityLogDescription": "Избришува логови на активности постари од определеното време.",
"TaskCleanActivityLog": "Избриши Лог на Активности",
- "External": "Надворешен"
+ "External": "Надворешен",
+ "HearingImpaired": "Оштетен слух"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json
index a07222975..ebd3f7560 100644
--- a/Emby.Server.Implementations/Localization/Core/ms.json
+++ b/Emby.Server.Implementations/Localization/Core/ms.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/or.json b/Emby.Server.Implementations/Localization/Core/or.json
index 0e9d81ee8..8251c1290 100644
--- a/Emby.Server.Implementations/Localization/Core/or.json
+++ b/Emby.Server.Implementations/Localization/Core/or.json
@@ -1,4 +1,12 @@
{
"External": "ବହିଃସ୍ଥ",
- "Genres": "ଧରଣ"
+ "Genres": "ଧରଣ",
+ "Albums": "ଆଲବମଗୁଡ଼ିକ",
+ "Artists": "କଳାକାରଗୁଡ଼ିକ",
+ "Application": "ଆପ୍ଲିକେସନ",
+ "Books": "ବହିଗୁଡ଼ିକ",
+ "Channels": "ଚ୍ୟାନେଲଗୁଡ଼ିକ",
+ "ChapterNameValue": "ବିଭାଗ {0}",
+ "Collections": "ସଂଗ୍ରହଗୁଡ଼ିକ",
+ "Folders": "ଫୋଲ୍ଡରଗୁଡ଼ିକ"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json
index 1944e072c..110af11b7 100644
--- a/Emby.Server.Implementations/Localization/Core/sl-SI.json
+++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json
@@ -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."
}
diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json
index 44ce4ac5b..e92752c5f 100644
--- a/Emby.Server.Implementations/Localization/Core/vi.json
+++ b/Emby.Server.Implementations/Localization/Core/vi.json
@@ -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."
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
index 1af2c96d2..efb6436ae 100644
--- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
@@ -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();
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index bbb3938dc..40b3b0339 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -394,6 +394,7 @@ namespace Emby.Server.Implementations.Session
session.PlayState.SubtitleStreamIndex = info.SubtitleStreamIndex;
session.PlayState.PlayMethod = info.PlayMethod;
session.PlayState.RepeatMode = info.RepeatMode;
+ session.PlayState.PlaybackOrder = info.PlaybackOrder;
session.PlaylistItemId = info.PlaylistItemId;
var nowPlayingQueue = info.NowPlayingQueue;
diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs
index e72bec46f..764c0a435 100644
--- a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs
+++ b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs
@@ -1,5 +1,6 @@
using System.Threading.Tasks;
using Jellyfin.Api.Extensions;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
@@ -25,15 +26,27 @@ namespace Jellyfin.Api.Auth.UserPermissionPolicy
/// <inheritdoc />
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement)
{
- var user = _userManager.GetUserById(context.User.GetUserId());
- if (user is null)
+ // Api keys have global permissions, so just succeed the requirement.
+ if (context.User.GetIsApiKey())
{
- throw new ResourceNotFoundException();
+ context.Succeed(requirement);
}
-
- if (user.HasPermission(requirement.RequiredPermission))
+ else
{
- context.Succeed(requirement);
+ var userId = context.User.GetUserId();
+ if (!userId.IsEmpty())
+ {
+ var user = _userManager.GetUserById(context.User.GetUserId());
+ if (user is null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ if (user.HasPermission(requirement.RequiredPermission))
+ {
+ context.Succeed(requirement);
+ }
+ }
}
return Task.CompletedTask;
diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
index 6f0006832..1cad66326 100644
--- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
+++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
@@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
+using Jellyfin.Api.Helpers;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Extensions;
@@ -48,15 +49,17 @@ public class DisplayPreferencesController : BaseJellyfinApiController
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
public ActionResult<DisplayPreferencesDto> GetDisplayPreferences(
[FromRoute, Required] string displayPreferencesId,
- [FromQuery, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromQuery, Required] string client)
{
+ userId = RequestHelpers.GetUserId(User, userId);
+
if (!Guid.TryParse(displayPreferencesId, out var itemId))
{
itemId = displayPreferencesId.GetMD5();
}
- var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
+ var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId.Value, itemId, client);
var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
itemPreferences.ItemId = itemId;
@@ -113,10 +116,12 @@ public class DisplayPreferencesController : BaseJellyfinApiController
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
public ActionResult UpdateDisplayPreferences(
[FromRoute, Required] string displayPreferencesId,
- [FromQuery, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromQuery, Required] string client,
[FromBody, Required] DisplayPreferencesDto displayPreferences)
{
+ userId = RequestHelpers.GetUserId(User, userId);
+
HomeSectionType[] defaults =
{
HomeSectionType.SmallLibraryTiles,
@@ -134,7 +139,7 @@ public class DisplayPreferencesController : BaseJellyfinApiController
itemId = displayPreferencesId.GetMD5();
}
- var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
+ var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId.Value, itemId, client);
existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null;
existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop;
existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar;
@@ -204,7 +209,7 @@ public class DisplayPreferencesController : BaseJellyfinApiController
itemPrefs.ItemId = itemId;
// Set all remaining custom preferences.
- _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
+ _displayPreferencesManager.SetCustomItemDisplayPreferences(userId.Value, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
_displayPreferencesManager.SaveChanges();
return NoContent();
diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs
index dda1e9d56..590cdc33f 100644
--- a/Jellyfin.Api/Controllers/DynamicHlsController.cs
+++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs
@@ -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);
}
- else
- {
- 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);
+ var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
+ var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength;
- if (currentTranscodingIndex.HasValue)
- {
- DeleteLastFile(playlistPath, segmentExtension, 0);
- }
+ 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;
+ }
- streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
+ if (startTranscoding)
+ {
+ // If the playlist doesn't already exist, startup ffmpeg
+ try
+ {
+ await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false)
+ .ConfigureAwait(false);
- state.WaitForPath = segmentPath;
- job = await _transcodeManager.StartFfMpeg(
- state,
- playlistPath,
- GetCommandLineArguments(playlistPath, state, false, segmentId),
- Request.HttpContext.User.GetUserId(),
- TranscodingJobType,
- cancellationTokenSource).ConfigureAwait(false);
- }
- catch
+ if (currentTranscodingIndex.HasValue)
{
- state.Dispose();
- throw;
+ DeleteLastFile(playlistPath, segmentExtension, 0);
}
- // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false);
+ 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);
}
- else
+ catch
{
- job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
- if (job?.TranscodingThrottler is not null)
- {
- await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
- }
+ state.Dispose();
+ throw;
}
+
+ // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false);
}
- }
- finally
- {
- if (!released)
+ else
{
- transcodingLock.Release();
+ job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
+ if (job?.TranscodingThrottler is not null)
+ {
+ await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
+ }
}
}
diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs
index e7ff1f986..3cf485299 100644
--- a/Jellyfin.Api/Controllers/InstantMixController.cs
+++ b/Jellyfin.Api/Controllers/InstantMixController.cs
@@ -53,7 +53,7 @@ public class InstantMixController : BaseJellyfinApiController
/// <summary>
/// Creates an instant playlist based on a given song.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
@@ -63,10 +63,10 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("Songs/{id}/InstantMix")]
+ [HttpGet("Songs/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong(
- [FromRoute, Required] Guid id,
+ [FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
@@ -75,7 +75,7 @@ public class InstantMixController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
- var item = _libraryManager.GetItemById(id);
+ var item = _libraryManager.GetItemById(itemId);
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
@@ -90,7 +90,7 @@ public class InstantMixController : BaseJellyfinApiController
/// <summary>
/// Creates an instant playlist based on a given album.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
@@ -100,10 +100,10 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("Albums/{id}/InstantMix")]
+ [HttpGet("Albums/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum(
- [FromRoute, Required] Guid id,
+ [FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
@@ -112,7 +112,7 @@ public class InstantMixController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
- var album = _libraryManager.GetItemById(id);
+ var album = _libraryManager.GetItemById(itemId);
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
@@ -127,7 +127,7 @@ public class InstantMixController : BaseJellyfinApiController
/// <summary>
/// Creates an instant playlist based on a given playlist.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
@@ -137,10 +137,10 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("Playlists/{id}/InstantMix")]
+ [HttpGet("Playlists/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist(
- [FromRoute, Required] Guid id,
+ [FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
@@ -149,7 +149,7 @@ public class InstantMixController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
- var playlist = (Playlist)_libraryManager.GetItemById(id);
+ var playlist = (Playlist)_libraryManager.GetItemById(itemId);
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
@@ -200,7 +200,7 @@ public class InstantMixController : BaseJellyfinApiController
/// <summary>
/// Creates an instant playlist based on a given artist.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
@@ -210,10 +210,10 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("Artists/{id}/InstantMix")]
+ [HttpGet("Artists/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
- [FromRoute, Required] Guid id,
+ [FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
@@ -222,7 +222,7 @@ public class InstantMixController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
- var item = _libraryManager.GetItemById(id);
+ var item = _libraryManager.GetItemById(itemId);
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
@@ -237,7 +237,7 @@ public class InstantMixController : BaseJellyfinApiController
/// <summary>
/// Creates an instant playlist based on a given item.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
@@ -247,10 +247,10 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("Items/{id}/InstantMix")]
+ [HttpGet("Items/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem(
- [FromRoute, Required] Guid id,
+ [FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
@@ -259,7 +259,7 @@ public class InstantMixController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
- var item = _libraryManager.GetItemById(id);
+ var item = _libraryManager.GetItemById(itemId);
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index a0bbc961f..984dc7789 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -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)
{
@@ -915,6 +913,7 @@ public class LibraryController : BaseJellyfinApiController
User.GetUserId())
{
ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()),
+ ItemId = item.Id.ToString("N", CultureInfo.InvariantCulture)
}).ConfigureAwait(false);
}
catch
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
index d483ca4d2..23c430f85 100644
--- a/Jellyfin.Api/Controllers/LibraryStructureController.cs
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -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
{
diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs
index 1b2f5750f..7768b3c45 100644
--- a/Jellyfin.Api/Controllers/LiveTvController.cs
+++ b/Jellyfin.Api/Controllers/LiveTvController.cs
@@ -43,7 +43,10 @@ namespace Jellyfin.Api.Controllers;
public class LiveTvController : BaseJellyfinApiController
{
private readonly ILiveTvManager _liveTvManager;
+ private readonly IGuideManager _guideManager;
private readonly ITunerHostManager _tunerHostManager;
+ private readonly IListingsManager _listingsManager;
+ private readonly IRecordingsManager _recordingsManager;
private readonly IUserManager _userManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILibraryManager _libraryManager;
@@ -56,7 +59,10 @@ 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="listingsManager">Instance of the <see cref="IListingsManager"/> interface.</param>
+ /// <param name="recordingsManager">Instance of the <see cref="IRecordingsManager"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
@@ -66,7 +72,10 @@ public class LiveTvController : BaseJellyfinApiController
/// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
public LiveTvController(
ILiveTvManager liveTvManager,
+ IGuideManager guideManager,
ITunerHostManager tunerHostManager,
+ IListingsManager listingsManager,
+ IRecordingsManager recordingsManager,
IUserManager userManager,
IHttpClientFactory httpClientFactory,
ILibraryManager libraryManager,
@@ -76,7 +85,10 @@ public class LiveTvController : BaseJellyfinApiController
ITranscodeManager transcodeManager)
{
_liveTvManager = liveTvManager;
+ _guideManager = guideManager;
_tunerHostManager = tunerHostManager;
+ _listingsManager = listingsManager;
+ _recordingsManager = recordingsManager;
_userManager = userManager;
_httpClientFactory = httpClientFactory;
_libraryManager = libraryManager;
@@ -624,7 +636,7 @@ public class LiveTvController : BaseJellyfinApiController
[Authorize(Policy = Policies.LiveTvAccess)]
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetPrograms([FromBody] GetProgramsDto body)
{
- var user = body.UserId.IsEmpty() ? null : _userManager.GetUserById(body.UserId);
+ var user = body.UserId.IsNullOrEmpty() ? null : _userManager.GetUserById(body.UserId.Value);
var query = new InternalItemsQuery(user)
{
@@ -941,9 +953,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.
@@ -1013,7 +1023,7 @@ public class LiveTvController : BaseJellyfinApiController
listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
}
- return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
+ return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
}
/// <summary>
@@ -1027,7 +1037,7 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DeleteListingProvider([FromQuery] string? id)
{
- _liveTvManager.DeleteListingsProvider(id);
+ _listingsManager.DeleteListingsProvider(id);
return NoContent();
}
@@ -1048,9 +1058,7 @@ public class LiveTvController : BaseJellyfinApiController
[FromQuery] string? type,
[FromQuery] string? location,
[FromQuery] string? country)
- {
- return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false);
- }
+ => await _listingsManager.GetLineups(type, id, country, location).ConfigureAwait(false);
/// <summary>
/// Gets available countries.
@@ -1081,48 +1089,20 @@ public class LiveTvController : BaseJellyfinApiController
[HttpGet("ChannelMappingOptions")]
[Authorize(Policy = Policies.LiveTvAccess)]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task<ActionResult<ChannelMappingOptionsDto>> GetChannelMappingOptions([FromQuery] string? providerId)
- {
- var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv");
-
- var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase));
-
- var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name;
-
- var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None)
- .ConfigureAwait(false);
-
- var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None)
- .ConfigureAwait(false);
-
- var mappings = listingsProviderInfo.ChannelMappings;
-
- return new ChannelMappingOptionsDto
- {
- TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(),
- ProviderChannels = providerChannels.Select(i => new NameIdPair
- {
- Name = i.Name,
- Id = i.Id
- }).ToList(),
- Mappings = mappings,
- ProviderName = listingsProviderName
- };
- }
+ public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
+ => _listingsManager.GetChannelMappingOptions(providerId);
/// <summary>
/// Set channel mappings.
/// </summary>
- /// <param name="setChannelMappingDto">The set channel mapping dto.</param>
+ /// <param name="dto">The set channel mapping dto.</param>
/// <response code="200">Created channel mapping returned.</response>
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
[HttpPost("ChannelMappings")]
[Authorize(Policy = Policies.LiveTvManagement)]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto)
- {
- return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false);
- }
+ public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
+ => _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
/// <summary>
/// Get tuner host types.
@@ -1164,8 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesVideoFile]
public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId)
{
- var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId);
-
+ var path = _recordingsManager.GetActiveRecordingPath(recordingId);
if (string.IsNullOrWhiteSpace(path))
{
return NotFound();
diff --git a/Jellyfin.Api/Controllers/LyricsController.cs b/Jellyfin.Api/Controllers/LyricsController.cs
new file mode 100644
index 000000000..4fccf2cb4
--- /dev/null
+++ b/Jellyfin.Api/Controllers/LyricsController.cs
@@ -0,0 +1,267 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Net.Mime;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Attributes;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Extensions;
+using MediaBrowser.Common.Api;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Lyrics;
+using MediaBrowser.Model.Providers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Lyrics controller.
+/// </summary>
+[Route("")]
+public class LyricsController : BaseJellyfinApiController
+{
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILyricManager _lyricManager;
+ private readonly IProviderManager _providerManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LyricsController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ public LyricsController(
+ ILibraryManager libraryManager,
+ ILyricManager lyricManager,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ IUserManager userManager)
+ {
+ _libraryManager = libraryManager;
+ _lyricManager = lyricManager;
+ _providerManager = providerManager;
+ _fileSystem = fileSystem;
+ _userManager = userManager;
+ }
+
+ /// <summary>
+ /// Gets an item's lyrics.
+ /// </summary>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Lyrics returned.</response>
+ /// <response code="404">Something went wrong. No Lyrics will be returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns>
+ [HttpGet("Audio/{itemId}/Lyrics")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<LyricDto>> GetLyrics([FromRoute, Required] Guid itemId)
+ {
+ var isApiKey = User.GetIsApiKey();
+ var userId = User.GetUserId();
+ if (!isApiKey && userId.IsEmpty())
+ {
+ return BadRequest();
+ }
+
+ var audio = _libraryManager.GetItemById<Audio>(itemId);
+ if (audio is null)
+ {
+ return NotFound();
+ }
+
+ if (!isApiKey)
+ {
+ var user = _userManager.GetUserById(userId);
+ if (user is null)
+ {
+ return NotFound();
+ }
+
+ // Check the item is visible for the user
+ if (!audio.IsVisible(user))
+ {
+ return Unauthorized($"{user.Username} is not permitted to access item {audio.Name}.");
+ }
+ }
+
+ var result = await _lyricManager.GetLyricsAsync(audio, CancellationToken.None).ConfigureAwait(false);
+ if (result is not null)
+ {
+ return Ok(result);
+ }
+
+ return NotFound();
+ }
+
+ /// <summary>
+ /// Upload an external lyric file.
+ /// </summary>
+ /// <param name="itemId">The item the lyric belongs to.</param>
+ /// <param name="fileName">Name of the file being uploaded.</param>
+ /// <response code="200">Lyrics uploaded.</response>
+ /// <response code="400">Error processing upload.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>The uploaded lyric.</returns>
+ [HttpPost("Audio/{itemId}/Lyrics")]
+ [Authorize(Policy = Policies.LyricManagement)]
+ [AcceptsFile(MediaTypeNames.Text.Plain)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<LyricDto>> UploadLyrics(
+ [FromRoute, Required] Guid itemId,
+ [FromQuery, Required] string fileName)
+ {
+ var audio = _libraryManager.GetItemById<Audio>(itemId);
+ if (audio is null)
+ {
+ return NotFound();
+ }
+
+ if (Request.ContentLength.GetValueOrDefault(0) == 0)
+ {
+ return BadRequest("No lyrics uploaded");
+ }
+
+ // Utilize Path.GetExtension as it provides extra path validation.
+ var format = Path.GetExtension(fileName.AsSpan()).RightPart('.').ToString();
+ if (string.IsNullOrEmpty(format))
+ {
+ return BadRequest("Extension is required on filename");
+ }
+
+ var stream = new MemoryStream();
+ await using (stream.ConfigureAwait(false))
+ {
+ await Request.Body.CopyToAsync(stream).ConfigureAwait(false);
+ var uploadedLyric = await _lyricManager.UploadLyricAsync(
+ audio,
+ new LyricResponse
+ {
+ Format = format,
+ Stream = stream
+ }).ConfigureAwait(false);
+
+ if (uploadedLyric is null)
+ {
+ return BadRequest();
+ }
+
+ _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+ return Ok(uploadedLyric);
+ }
+ }
+
+ /// <summary>
+ /// Deletes an external lyric file.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <response code="204">Lyric deleted.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("Audio/{itemId}/Lyrics")]
+ [Authorize(Policy = Policies.LyricManagement)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult> DeleteLyrics(
+ [FromRoute, Required] Guid itemId)
+ {
+ var audio = _libraryManager.GetItemById<Audio>(itemId);
+ if (audio is null)
+ {
+ return NotFound();
+ }
+
+ await _lyricManager.DeleteLyricsAsync(audio).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Search remote lyrics.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <response code="200">Lyrics retrieved.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>An array of <see cref="RemoteLyricInfo"/>.</returns>
+ [HttpGet("Audio/{itemId}/RemoteSearch/Lyrics")]
+ [Authorize(Policy = Policies.LyricManagement)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<IReadOnlyList<RemoteLyricInfoDto>>> SearchRemoteLyrics([FromRoute, Required] Guid itemId)
+ {
+ var audio = _libraryManager.GetItemById<Audio>(itemId);
+ if (audio is null)
+ {
+ return NotFound();
+ }
+
+ var results = await _lyricManager.SearchLyricsAsync(audio, false, CancellationToken.None).ConfigureAwait(false);
+ return Ok(results);
+ }
+
+ /// <summary>
+ /// Downloads a remote lyric.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="lyricId">The lyric id.</param>
+ /// <response code="200">Lyric downloaded.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("Audio/{itemId}/RemoteSearch/Lyrics/{lyricId}")]
+ [Authorize(Policy = Policies.LyricManagement)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<LyricDto>> DownloadRemoteLyrics(
+ [FromRoute, Required] Guid itemId,
+ [FromRoute, Required] string lyricId)
+ {
+ var audio = _libraryManager.GetItemById<Audio>(itemId);
+ if (audio is null)
+ {
+ return NotFound();
+ }
+
+ var downloadedLyrics = await _lyricManager.DownloadLyricsAsync(audio, lyricId, CancellationToken.None).ConfigureAwait(false);
+ if (downloadedLyrics is null)
+ {
+ return NotFound();
+ }
+
+ _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+ return Ok(downloadedLyrics);
+ }
+
+ /// <summary>
+ /// Gets the remote lyrics.
+ /// </summary>
+ /// <param name="lyricId">The remote provider item id.</param>
+ /// <response code="200">File returned.</response>
+ /// <response code="404">Lyric not found.</response>
+ /// <returns>A <see cref="FileStreamResult"/> with the lyric file.</returns>
+ [HttpGet("Providers/Lyrics/{lyricId}")]
+ [Authorize(Policy = Policies.LyricManagement)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<LyricDto>> GetRemoteLyrics([FromRoute, Required] string lyricId)
+ {
+ var result = await _lyricManager.GetRemoteLyricsAsync(lyricId, CancellationToken.None).ConfigureAwait(false);
+ if (result is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(result);
+ }
+}
diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs
index bea545cfd..742012b71 100644
--- a/Jellyfin.Api/Controllers/MediaInfoController.cs
+++ b/Jellyfin.Api/Controllers/MediaInfoController.cs
@@ -64,8 +64,9 @@ public class MediaInfoController : BaseJellyfinApiController
/// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns>
[HttpGet("Items/{itemId}/PlaybackInfo")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId)
+ public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
{
+ userId = RequestHelpers.GetUserId(User, userId);
return await _mediaInfoHelper.GetPlaybackInfo(
itemId,
userId)
diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs
index 921cc6031..0e7c3f155 100644
--- a/Jellyfin.Api/Controllers/PlaylistsController.cs
+++ b/Jellyfin.Api/Controllers/PlaylistsController.cs
@@ -174,7 +174,7 @@ public class PlaylistsController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems(
[FromRoute, Required] Guid playlistId,
- [FromQuery, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
@@ -183,15 +183,16 @@ public class PlaylistsController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
+ userId = RequestHelpers.GetUserId(User, userId);
var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
if (playlist is null)
{
return NotFound();
}
- var user = userId.IsEmpty()
+ var user = userId.IsNullOrEmpty()
? null
- : _userManager.GetUserById(userId);
+ : _userManager.GetUserById(userId.Value);
var items = playlist.GetManageableItems().ToArray();
var count = items.Length;
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index 49ca058bd..cc2a630e1 100644
--- a/Jellyfin.Api/Controllers/SubtitleController.cs
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -11,7 +11,6 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Models.SubtitleDtos;
using MediaBrowser.Common.Api;
@@ -162,17 +161,17 @@ public class SubtitleController : BaseJellyfinApiController
/// <summary>
/// Gets the remote subtitles.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="subtitleId">The item id.</param>
/// <response code="200">File returned.</response>
/// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns>
- [HttpGet("Providers/Subtitles/Subtitles/{id}")]
+ [HttpGet("Providers/Subtitles/Subtitles/{subtitleId}")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Application.Octet)]
[ProducesFile("text/*")]
- public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string id)
+ public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string subtitleId)
{
- var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false);
+ var result = await _subtitleManager.GetRemoteSubtitles(subtitleId, CancellationToken.None).ConfigureAwait(false);
return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format));
}
@@ -407,22 +406,29 @@ public class SubtitleController : BaseJellyfinApiController
[FromBody, Required] UploadSubtitleDto body)
{
var video = (Video)_libraryManager.GetItemById(itemId);
- var stream = new CryptoStream(Request.Body, new FromBase64Transform(), CryptoStreamMode.Read);
- await using (stream.ConfigureAwait(false))
- {
- await _subtitleManager.UploadSubtitle(
- video,
- new SubtitleResponse
- {
- Format = body.Format,
- Language = body.Language,
- IsForced = body.IsForced,
- IsHearingImpaired = body.IsHearingImpaired,
- Stream = stream
- }).ConfigureAwait(false);
- _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
- return NoContent();
+ var bytes = Encoding.UTF8.GetBytes(body.Data);
+ var memoryStream = new MemoryStream(bytes, 0, bytes.Length, false, true);
+ await using (memoryStream.ConfigureAwait(false))
+ {
+ using var transform = new FromBase64Transform();
+ var stream = new CryptoStream(memoryStream, transform, CryptoStreamMode.Read);
+ await using (stream.ConfigureAwait(false))
+ {
+ await _subtitleManager.UploadSubtitle(
+ video,
+ new SubtitleResponse
+ {
+ Format = body.Format,
+ Language = body.Language,
+ IsForced = body.IsForced,
+ IsHearingImpaired = body.IsHearingImpaired,
+ Stream = stream
+ }).ConfigureAwait(false);
+ _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+
+ return NoContent();
+ }
}
}
diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs
index 3d4df0386..6c5ce4715 100644
--- a/Jellyfin.Api/Controllers/SystemController.cs
+++ b/Jellyfin.Api/Controllers/SystemController.cs
@@ -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;
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index 264e0a3db..e3bfd4ea9 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -18,6 +18,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Lyrics;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -539,48 +540,4 @@ public class UserLibraryController : BaseJellyfinApiController
return _userDataRepository.GetUserDataDto(item, user);
}
-
- /// <summary>
- /// Gets an item's lyrics.
- /// </summary>
- /// <param name="userId">User id.</param>
- /// <param name="itemId">Item id.</param>
- /// <response code="200">Lyrics returned.</response>
- /// <response code="404">Something went wrong. No Lyrics will be returned.</response>
- /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns>
- [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task<ActionResult<LyricResponse>> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
- {
- var user = _userManager.GetUserById(userId);
-
- if (user is null)
- {
- return NotFound();
- }
-
- var item = itemId.IsEmpty()
- ? _libraryManager.GetUserRootFolder()
- : _libraryManager.GetItemById(itemId);
-
- if (item is null)
- {
- return NotFound();
- }
-
- if (item is not UserRootFolder
- // Check the item is visible for the user
- && !item.IsVisible(user))
- {
- return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
- }
-
- var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false);
- if (result is not null)
- {
- return Ok(result);
- }
-
- return NotFound();
- }
}
diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs
index e6c319869..b3029d6fa 100644
--- a/Jellyfin.Api/Controllers/VideosController.cs
+++ b/Jellyfin.Api/Controllers/VideosController.cs
@@ -458,10 +458,8 @@ public class VideosController : BaseJellyfinApiController
return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically");
}
- var outputPath = state.OutputFilePath;
-
// Static stream
- if (@static.HasValue && @static.Value)
+ if (@static.HasValue && @static.Value && !(state.MediaSource.VideoType == VideoType.BluRay || state.MediaSource.VideoType == VideoType.Dvd))
{
var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
@@ -478,7 +476,7 @@ public class VideosController : BaseJellyfinApiController
// Need to start ffmpeg (because media can't be returned directly)
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
- var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast");
+ var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, "superfast");
return await FileStreamResponseHelpers.GetTranscodedFile(
state,
isHeadRequest,
diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
index fa81fc284..f8d89119a 100644
--- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
+++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
@@ -211,19 +211,8 @@ public class DynamicHlsHelper
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
sdrVideoUrl += "&AllowVideoStreamCopy=false";
- var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
- var sdrOutputAudioBitrate = 0;
- if (EncodingHelper.LosslessAudioCodecs.Contains(state.VideoRequest.AudioCodec, StringComparison.OrdinalIgnoreCase))
- {
- sdrOutputAudioBitrate = state.AudioStream.BitRate ?? 0;
- }
- else
- {
- sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream, state.OutputAudioChannels) ?? 0;
- }
-
- var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate;
- AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup);
+ // HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
+ AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
// Restore the video codec
state.OutputVideoCodec = "copy";
@@ -325,6 +314,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 +324,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
diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
index 5385979d4..cb178a61d 100644
--- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
+++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
@@ -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();
- }
}
}
diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs
index 7a3842a9f..bfe71fd87 100644
--- a/Jellyfin.Api/Helpers/StreamingHelpers.cs
+++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs
@@ -225,7 +225,7 @@ public static class StreamingHelpers
var ext = string.IsNullOrWhiteSpace(state.OutputContainer)
? GetOutputFileExtension(state, mediaSource)
- : ("." + state.OutputContainer);
+ : ("." + GetContainerFileExtension(state.OutputContainer));
state.OutputFilePath = GetOutputFilePath(state, ext, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId);
@@ -559,4 +559,23 @@ public static class StreamingHelpers
}
}
}
+
+ /// <summary>
+ /// Parses the container into its file extension.
+ /// </summary>
+ /// <param name="container">The container.</param>
+ private static string? GetContainerFileExtension(string? container)
+ {
+ if (string.Equals(container, "mpegts", StringComparison.OrdinalIgnoreCase))
+ {
+ return "ts";
+ }
+
+ if (string.Equals(container, "matroska", StringComparison.OrdinalIgnoreCase))
+ {
+ return "mkv";
+ }
+
+ return container;
+ }
}
diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
index 6a30de5e6..8482b1cf1 100644
--- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
+++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
@@ -22,7 +22,7 @@ public class GetProgramsDto
/// <summary>
/// Gets or sets optional. Filter by user id.
/// </summary>
- public Guid UserId { get; set; }
+ public Guid? UserId { get; set; }
/// <summary>
/// Gets or sets the minimum premiere start date.
diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs
index acd3f29e3..12ce19368 100644
--- a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs
+++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs
@@ -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>
diff --git a/Jellyfin.Data/Entities/User.cs b/Jellyfin.Data/Entities/User.cs
index ea0de3016..2c9cc8d78 100644
--- a/Jellyfin.Data/Entities/User.cs
+++ b/Jellyfin.Data/Entities/User.cs
@@ -506,6 +506,7 @@ namespace Jellyfin.Data.Entities
Permissions.Add(new Permission(PermissionKind.EnableRemoteControlOfOtherUsers, false));
Permissions.Add(new Permission(PermissionKind.EnableCollectionManagement, false));
Permissions.Add(new Permission(PermissionKind.EnableSubtitleManagement, false));
+ Permissions.Add(new Permission(PermissionKind.EnableLyricManagement, false));
}
/// <summary>
diff --git a/Jellyfin.Data/Enums/PermissionKind.cs b/Jellyfin.Data/Enums/PermissionKind.cs
index 6644f0151..c3d6705c2 100644
--- a/Jellyfin.Data/Enums/PermissionKind.cs
+++ b/Jellyfin.Data/Enums/PermissionKind.cs
@@ -118,6 +118,11 @@ namespace Jellyfin.Data.Enums
/// <summary>
/// Whether the user can edit subtitles.
/// </summary>
- EnableSubtitleManagement = 22
+ EnableSubtitleManagement = 22,
+
+ /// <summary>
+ /// Whether the user can edit lyrics.
+ /// </summary>
+ EnableLyricManagement = 23,
}
}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs
new file mode 100644
index 000000000..bd717b0af
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using Episode = MediaBrowser.Controller.Entities.TV.Episode;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Library;
+
+/// <summary>
+/// Creates an entry in the activity log whenever a lyric download fails.
+/// </summary>
+public class LyricDownloadFailureLogger : IEventConsumer<LyricDownloadFailureEventArgs>
+{
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LyricDownloadFailureLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public LyricDownloadFailureLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(LyricDownloadFailureEventArgs eventArgs)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("LyricDownloadFailureFromForItem"),
+ eventArgs.Provider,
+ GetItemName(eventArgs.Item)),
+ "LyricDownloadFailure",
+ Guid.Empty)
+ {
+ ItemId = eventArgs.Item.Id.ToString("N", CultureInfo.InvariantCulture),
+ ShortOverview = eventArgs.Exception.Message
+ }).ConfigureAwait(false);
+ }
+
+ private static string GetItemName(BaseItem item)
+ {
+ var name = item.Name;
+ if (item is Episode episode)
+ {
+ if (episode.IndexNumber.HasValue)
+ {
+ name = string.Format(
+ CultureInfo.InvariantCulture,
+ "Ep{0} - {1}",
+ episode.IndexNumber.Value,
+ name);
+ }
+
+ if (episode.ParentIndexNumber.HasValue)
+ {
+ name = string.Format(
+ CultureInfo.InvariantCulture,
+ "S{0}, {1}",
+ episode.ParentIndexNumber.Value,
+ name);
+ }
+ }
+
+ if (item is IHasSeries hasSeries)
+ {
+ name = hasSeries.SeriesName + " - " + name;
+ }
+
+ if (item is IHasAlbumArtist hasAlbumArtist)
+ {
+ var artists = hasAlbumArtist.AlbumArtists;
+
+ if (artists.Count > 0)
+ {
+ name = artists[0] + " - " + name;
+ }
+ }
+ else if (item is IHasArtist hasArtist)
+ {
+ var artists = hasArtist.Artists;
+
+ if (artists.Count > 0)
+ {
+ name = artists[0] + " - " + name;
+ }
+ }
+
+ return name;
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs
index 9626817e9..d1db6d3b4 100644
--- a/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs
+++ b/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs
@@ -12,6 +12,7 @@ using MediaBrowser.Controller.Events.Authentication;
using MediaBrowser.Controller.Events.Session;
using MediaBrowser.Controller.Events.Updates;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.DependencyInjection;
@@ -30,6 +31,7 @@ namespace Jellyfin.Server.Implementations.Events
public static void AddEventServices(this IServiceCollection collection)
{
// Library consumers
+ collection.AddScoped<IEventConsumer<LyricDownloadFailureEventArgs>, LyricDownloadFailureLogger>();
collection.AddScoped<IEventConsumer<SubtitleDownloadFailureEventArgs>, SubtitleDownloadFailureLogger>();
// Security consumers
diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
index 0ed1578c7..7c4155bfc 100644
--- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
+++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
@@ -26,6 +26,7 @@
</ItemGroup>
<ItemGroup>
+ <PackageReference Include="AsyncKeyedLock" />
<PackageReference Include="EFCoreSecondLevelCacheInterceptor" />
<PackageReference Include="System.Linq.Async" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
index b960feb7f..095bc9ed3 100644
--- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
+++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
@@ -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)
+ try
{
- _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
- return;
- }
+ 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;
+ }
- var mediaPath = mediaSource.Path;
- var mediaStream = mediaSource.VideoStream;
- var container = mediaSource.Container;
+ // 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));
- _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 (mediaSource is null)
+ {
+ _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
+ return;
+ }
- if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
- {
- throw new InvalidOperationException("Null or invalid directory from media encoder.");
- }
+ 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();
+ 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);
+ // Create tiles
+ var trickplayInfo = CreateTiles(images, width, options, outputDir);
- // Save tiles info
- try
- {
- if (trickplayInfo is not null)
+ // Save tiles info
+ try
{
- trickplayInfo.ItemId = video.Id;
- await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
+ if (trickplayInfo is not null)
+ {
+ trickplayInfo.ItemId = video.Id;
+ await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
- _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
+ _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
+ }
+ else
+ {
+ throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
+ }
}
- else
+ catch (Exception ex)
{
- throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
+ _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);
+ }
}
}
}
@@ -382,7 +382,7 @@ public class TrickplayManager : ITrickplayManager
if (trickplayInfo.ThumbnailCount > 0)
{
- const string urlFormat = "Trickplay/{0}/{1}.jpg?MediaSourceId={2}&api_key={3}";
+ const string urlFormat = "{0}.jpg?MediaSourceId={1}&api_key={2}";
const string decimalFormat = "{0:0.###}";
var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}";
@@ -431,7 +431,6 @@ public class TrickplayManager : ITrickplayManager
.AppendFormat(
CultureInfo.InvariantCulture,
urlFormat,
- width.ToString(CultureInfo.InvariantCulture),
i.ToString(CultureInfo.InvariantCulture),
itemId.ToString("N"),
apiKey)
diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs
deleted file mode 100644
index a471ea1d5..000000000
--- a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs
+++ /dev/null
@@ -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);
- }
- }
- }
- }
-}
diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs
new file mode 100644
index 000000000..e40b541a3
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs
@@ -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);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index c4a2bfdb8..41f1ac351 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -688,6 +688,7 @@ namespace Jellyfin.Server.Implementations.Users
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
+ user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index 46df173bf..597643ed1 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -37,7 +37,6 @@ using Microsoft.OpenApi.Interfaces;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
-using IPNetwork = System.Net.IPNetwork;
namespace Jellyfin.Server.Extensions
{
@@ -83,6 +82,7 @@ namespace Jellyfin.Server.Extensions
options.AddPolicy(Policies.SyncPlayJoinGroup, new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.JoinGroup));
options.AddPolicy(Policies.SyncPlayIsInGroup, new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.IsInGroup));
options.AddPolicy(Policies.SubtitleManagement, new UserPermissionRequirement(PermissionKind.EnableSubtitleManagement));
+ options.AddPolicy(Policies.LyricManagement, new UserPermissionRequirement(PermissionKind.EnableLyricManagement));
options.AddPolicy(
Policies.RequiresElevation,
policy => policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication)
diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj
index 21c6e6f01..e18212908 100644
--- a/Jellyfin.Server/Jellyfin.Server.csproj
+++ b/Jellyfin.Server/Jellyfin.Server.csproj
@@ -12,6 +12,7 @@
<ServerGarbageCollection>false</ServerGarbageCollection>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <ApplicationIcon>Jellyfin.Server.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
@@ -19,6 +20,10 @@
</ItemGroup>
<ItemGroup>
+ <Content Include="Jellyfin.Server.ico" />
+ </ItemGroup>
+
+ <ItemGroup>
<EmbeddedResource Include="Resources/Configuration/*" />
</ItemGroup>
diff --git a/Jellyfin.Server/Jellyfin.Server.ico b/Jellyfin.Server/Jellyfin.Server.ico
new file mode 100644
index 000000000..0872b956a
--- /dev/null
+++ b/Jellyfin.Server/Jellyfin.Server.ico
Binary files differ
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 7d5f22545..e9fb3e4c2 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -4,8 +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.Extensions;
+using Jellyfin.LiveTv.Recordings;
using Jellyfin.MediaEncoding.Hls.Extensions;
using Jellyfin.Networking;
using Jellyfin.Networking.HappyEyeballs;
@@ -17,6 +19,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 +127,13 @@ namespace Jellyfin.Server
services.AddHlsPlaylistGenerator();
services.AddLiveTvServices();
+ services.AddHostedService<RecordingsHost>();
services.AddHostedService<AutoDiscoveryHost>();
+ services.AddHostedService<PortForwardingHost>();
+ services.AddHostedService<NfoUserDataSaver>();
+ services.AddHostedService<LibraryChangedNotifier>();
+ services.AddHostedService<UserDataChangeNotifier>();
+ services.AddHostedService<RecordingNotifier>();
}
/// <summary>
diff --git a/MediaBrowser.Common/Api/Policies.cs b/MediaBrowser.Common/Api/Policies.cs
index e5427b8ef..435f4798f 100644
--- a/MediaBrowser.Common/Api/Policies.cs
+++ b/MediaBrowser.Common/Api/Policies.cs
@@ -89,4 +89,9 @@ public static class Policies
/// Policy name for accessing subtitles management.
/// </summary>
public const string SubtitleManagement = "SubtitleManagement";
+
+ /// <summary>
+ /// Policy name for accessing lyric management.
+ /// </summary>
+ public const string LyricManagement = "LyricManagement";
}
diff --git a/MediaBrowser.Common/Progress/ActionableProgress.cs b/MediaBrowser.Common/Progress/ActionableProgress.cs
deleted file mode 100644
index 0ba46ea3b..000000000
--- a/MediaBrowser.Common/Progress/ActionableProgress.cs
+++ /dev/null
@@ -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);
- }
- }
-}
diff --git a/MediaBrowser.Common/Progress/SimpleProgress.cs b/MediaBrowser.Common/Progress/SimpleProgress.cs
deleted file mode 100644
index 7071f2bc3..000000000
--- a/MediaBrowser.Common/Progress/SimpleProgress.cs
+++ /dev/null
@@ -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);
- }
- }
-}
diff --git a/MediaBrowser.Controller/Channels/Channel.cs b/MediaBrowser.Controller/Channels/Channel.cs
index 94418683b..f186523b9 100644
--- a/MediaBrowser.Controller/Channels/Channel.cs
+++ b/MediaBrowser.Controller/Channels/Channel.cs
@@ -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
{
diff --git a/MediaBrowser.Controller/Drawing/ImageStream.cs b/MediaBrowser.Controller/Drawing/ImageStream.cs
deleted file mode 100644
index f4c305799..000000000
--- a/MediaBrowser.Controller/Drawing/ImageStream.cs
+++ /dev/null
@@ -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();
- }
- }
- }
-}
diff --git a/MediaBrowser.Controller/Entities/Audio/Audio.cs b/MediaBrowser.Controller/Entities/Audio/Audio.cs
index 243d2f04f..709d4b70c 100644
--- a/MediaBrowser.Controller/Entities/Audio/Audio.cs
+++ b/MediaBrowser.Controller/Entities/Audio/Audio.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
+using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
@@ -27,6 +28,7 @@ namespace MediaBrowser.Controller.Entities.Audio
{
Artists = Array.Empty<string>();
AlbumArtists = Array.Empty<string>();
+ LyricFiles = Array.Empty<string>();
}
/// <inheritdoc />
@@ -65,6 +67,16 @@ namespace MediaBrowser.Controller.Entities.Audio
[JsonIgnore]
public override MediaType MediaType => MediaType.Audio;
+ /// <summary>
+ /// Gets or sets a value indicating whether this audio has lyrics.
+ /// </summary>
+ public bool? HasLyrics { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of lyric paths.
+ /// </summary>
+ public IReadOnlyList<string> LyricFiles { get; set; }
+
public override double GetDefaultPrimaryImageAspectRatio()
{
return 1;
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index 74eb089de..1f13c833b 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -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);
@@ -922,7 +931,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
{
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index 5adadec39..04f47b729 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -171,7 +171,7 @@ namespace MediaBrowser.Controller.Entities
[JsonIgnore]
public override bool HasLocalAlternateVersions => LocalAlternateVersions.Length > 0;
- public static ILiveTvManager LiveTvManager { get; set; }
+ public static IRecordingsManager RecordingsManager { get; set; }
[JsonIgnore]
public override SourceType SourceType
@@ -334,7 +334,7 @@ namespace MediaBrowser.Controller.Entities
protected override bool IsActiveRecording()
{
- return LiveTvManager.GetActiveRecordingInfo(Path) is not null;
+ return RecordingsManager.GetActiveRecordingInfo(Path) is not null;
}
public override bool CanDelete()
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 9ec22324f..e44c09783 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -169,6 +169,15 @@ namespace MediaBrowser.Controller.Library
BaseItem GetItemById(Guid id);
/// <summary>
+ /// Gets the item by id, as T.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <typeparam name="T">The type of item.</typeparam>
+ /// <returns>The item.</returns>
+ T GetItemById<T>(Guid id)
+ where T : BaseItem;
+
+ /// <summary>
/// Gets the intros.
/// </summary>
/// <param name="item">The item.</param>
diff --git a/MediaBrowser.Controller/Library/ILibraryMonitor.cs b/MediaBrowser.Controller/Library/ILibraryMonitor.cs
index de74aa5a1..6d2f5b873 100644
--- a/MediaBrowser.Controller/Library/ILibraryMonitor.cs
+++ b/MediaBrowser.Controller/Library/ILibraryMonitor.cs
@@ -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.
diff --git a/MediaBrowser.Controller/LiveTv/IGuideManager.cs b/MediaBrowser.Controller/LiveTv/IGuideManager.cs
new file mode 100644
index 000000000..9883b9283
--- /dev/null
+++ b/MediaBrowser.Controller/LiveTv/IGuideManager.cs
@@ -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);
+}
diff --git a/MediaBrowser.Controller/LiveTv/IListingsManager.cs b/MediaBrowser.Controller/LiveTv/IListingsManager.cs
new file mode 100644
index 000000000..bbf569575
--- /dev/null
+++ b/MediaBrowser.Controller/LiveTv/IListingsManager.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.LiveTv;
+
+namespace MediaBrowser.Controller.LiveTv;
+
+/// <summary>
+/// Service responsible for managing <see cref="IListingsProvider"/>s and mapping
+/// their channels to channels provided by <see cref="ITunerHost"/>s.
+/// </summary>
+public interface IListingsManager
+{
+ /// <summary>
+ /// Saves the listing provider.
+ /// </summary>
+ /// <param name="info">The listing provider information.</param>
+ /// <param name="validateLogin">A value indicating whether to validate login.</param>
+ /// <param name="validateListings">A value indicating whether to validate listings..</param>
+ /// <returns>Task.</returns>
+ Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings);
+
+ /// <summary>
+ /// Deletes the listing provider.
+ /// </summary>
+ /// <param name="id">The listing provider's id.</param>
+ void DeleteListingsProvider(string? id);
+
+ /// <summary>
+ /// Gets the lineups.
+ /// </summary>
+ /// <param name="providerType">Type of the provider.</param>
+ /// <param name="providerId">The provider identifier.</param>
+ /// <param name="country">The country.</param>
+ /// <param name="location">The location.</param>
+ /// <returns>The available lineups.</returns>
+ Task<List<NameIdPair>> GetLineups(string? providerType, string? providerId, string? country, string? location);
+
+ /// <summary>
+ /// Gets the programs for a provided channel.
+ /// </summary>
+ /// <param name="channel">The channel to retrieve programs for.</param>
+ /// <param name="startDateUtc">The earliest date to retrieve programs for.</param>
+ /// <param name="endDateUtc">The latest date to retrieve programs for.</param>
+ /// <param name="cancellationToken">The <see cref="CancellationToken"/> to use.</param>
+ /// <returns>The available programs.</returns>
+ Task<IEnumerable<ProgramInfo>> GetProgramsAsync(
+ ChannelInfo channel,
+ DateTime startDateUtc,
+ DateTime endDateUtc,
+ CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Adds metadata from the <see cref="IListingsProvider"/>s to the provided channels.
+ /// </summary>
+ /// <param name="channels">The channels.</param>
+ /// <param name="enableCache">A value indicating whether to use the EPG channel cache.</param>
+ /// <param name="cancellationToken">The <see cref="CancellationToken"/> to use.</param>
+ /// <returns>A task representing the metadata population.</returns>
+ Task AddProviderMetadata(IList<ChannelInfo> channels, bool enableCache, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Gets the channel mapping options for a provider.
+ /// </summary>
+ /// <param name="providerId">The id of the provider to use.</param>
+ /// <returns>The channel mapping options.</returns>
+ Task<ChannelMappingOptionsDto> GetChannelMappingOptions(string? providerId);
+
+ /// <summary>
+ /// Sets the channel mapping.
+ /// </summary>
+ /// <param name="providerId">The id of the provider for the mapping.</param>
+ /// <param name="tunerChannelNumber">The tuner channel number.</param>
+ /// <param name="providerChannelNumber">The provider channel number.</param>
+ /// <returns>The updated channel mapping.</returns>
+ Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber);
+}
diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
index 26f9fe42d..c0e46ba24 100644
--- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
+++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
@@ -10,7 +10,6 @@ using Jellyfin.Data.Entities;
using Jellyfin.Data.Events;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Querying;
@@ -36,8 +35,6 @@ namespace MediaBrowser.Controller.LiveTv
/// <value>The services.</value>
IReadOnlyList<ILiveTvService> Services { get; }
- IReadOnlyList<IListingsProvider> ListingProviders { get; }
-
/// <summary>
/// Gets the new timer defaults asynchronous.
/// </summary>
@@ -68,13 +65,6 @@ namespace MediaBrowser.Controller.LiveTv
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>
/// <param name="id">The identifier.</param>
@@ -115,16 +105,6 @@ namespace MediaBrowser.Controller.LiveTv
Task<QueryResult<SeriesTimerInfoDto>> GetSeriesTimers(SeriesTimerQuery query, CancellationToken cancellationToken);
/// <summary>
- /// Gets the channel stream.
- /// </summary>
- /// <param name="id">The identifier.</param>
- /// <param name="mediaSourceId">The media source identifier.</param>
- /// <param name="currentLiveStreams">The current live streams.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task{StreamResponseInfo}.</returns>
- Task<Tuple<MediaSourceInfo, ILiveStream>> GetChannelStream(string id, string mediaSourceId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken);
-
- /// <summary>
/// Gets the program.
/// </summary>
/// <param name="id">The identifier.</param>
@@ -175,12 +155,6 @@ namespace MediaBrowser.Controller.LiveTv
Task CreateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken);
/// <summary>
- /// Gets the guide information.
- /// </summary>
- /// <returns>GuideInfo.</returns>
- GuideInfo GetGuideInfo();
-
- /// <summary>
/// Gets the recommended programs.
/// </summary>
/// <param name="query">The query.</param>
@@ -236,14 +210,6 @@ namespace MediaBrowser.Controller.LiveTv
QueryResult<BaseItem> GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken);
/// <summary>
- /// Gets the channel media sources.
- /// </summary>
- /// <param name="item">Item to search for.</param>
- /// <param name="cancellationToken">CancellationToken to use for operation.</param>
- /// <returns>Channel media sources wrapped in a task.</returns>
- Task<IEnumerable<MediaSourceInfo>> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken);
-
- /// <summary>
/// Adds the information to program dto.
/// </summary>
/// <param name="programs">The programs.</param>
@@ -253,31 +219,6 @@ namespace MediaBrowser.Controller.LiveTv
Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem Item, BaseItemDto ItemDto)> programs, IReadOnlyList<ItemFields> fields, User user = null);
/// <summary>
- /// Saves the listing provider.
- /// </summary>
- /// <param name="info">The information.</param>
- /// <param name="validateLogin">if set to <c>true</c> [validate login].</param>
- /// <param name="validateListings">if set to <c>true</c> [validate listings].</param>
- /// <returns>Task.</returns>
- Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings);
-
- void DeleteListingsProvider(string id);
-
- Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber);
-
- TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List<ChannelInfo> providerChannels);
-
- /// <summary>
- /// Gets the lineups.
- /// </summary>
- /// <param name="providerType">Type of the provider.</param>
- /// <param name="providerId">The provider identifier.</param>
- /// <param name="country">The country.</param>
- /// <param name="location">The location.</param>
- /// <returns>Task&lt;List&lt;NameIdPair&gt;&gt;.</returns>
- Task<List<NameIdPair>> GetLineups(string providerType, string providerId, string country, string location);
-
- /// <summary>
/// Adds the channel information.
/// </summary>
/// <param name="items">The items.</param>
@@ -285,14 +226,6 @@ namespace MediaBrowser.Controller.LiveTv
/// <param name="user">The user.</param>
void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user);
- Task<List<ChannelInfo>> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken);
-
- Task<List<ChannelInfo>> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken);
-
- string GetEmbyTvActiveRecordingPath(string id);
-
- ActiveRecordingInfo GetActiveRecordingInfo(string path);
-
void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, ActiveRecordingInfo activeRecordingInfo, User user = null);
Task<BaseItem[]> GetRecordingFoldersAsync(User user);
diff --git a/MediaBrowser.Controller/LiveTv/IRecordingsManager.cs b/MediaBrowser.Controller/LiveTv/IRecordingsManager.cs
new file mode 100644
index 000000000..b918e2931
--- /dev/null
+++ b/MediaBrowser.Controller/LiveTv/IRecordingsManager.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+
+namespace MediaBrowser.Controller.LiveTv;
+
+/// <summary>
+/// Service responsible for managing LiveTV recordings.
+/// </summary>
+public interface IRecordingsManager
+{
+ /// <summary>
+ /// Gets the path for the provided timer id.
+ /// </summary>
+ /// <param name="id">The timer id.</param>
+ /// <returns>The recording path, or <c>null</c> if none exists.</returns>
+ string? GetActiveRecordingPath(string id);
+
+ /// <summary>
+ /// Gets the information for an active recording.
+ /// </summary>
+ /// <param name="path">The recording path.</param>
+ /// <returns>The <see cref="ActiveRecordingInfo"/>, or <c>null</c> if none exists.</returns>
+ ActiveRecordingInfo? GetActiveRecordingInfo(string path);
+
+ /// <summary>
+ /// Gets the recording folders.
+ /// </summary>
+ /// <returns>The <see cref="VirtualFolderInfo"/> for each recording folder.</returns>
+ IEnumerable<VirtualFolderInfo> GetRecordingFolders();
+
+ /// <summary>
+ /// Ensures that the recording folders all exist, and removes unused folders.
+ /// </summary>
+ /// <returns>Task.</returns>
+ Task CreateRecordingFolders();
+
+ /// <summary>
+ /// Cancels the recording with the provided timer id, if one is active.
+ /// </summary>
+ /// <param name="timerId">The timer id.</param>
+ /// <param name="timer">The timer.</param>
+ void CancelRecording(string timerId, TimerInfo? timer);
+
+ /// <summary>
+ /// Records a stream.
+ /// </summary>
+ /// <param name="recordingInfo">The recording info.</param>
+ /// <param name="channel">The channel associated with the recording timer.</param>
+ /// <param name="recordingEndDate">The time to stop recording.</param>
+ /// <returns>Task representing the recording process.</returns>
+ Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate);
+}
diff --git a/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs b/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs
deleted file mode 100644
index 1c1a4417d..000000000
--- a/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-#nullable disable
-
-#pragma warning disable CS1591
-
-namespace MediaBrowser.Controller.LiveTv
-{
- public class TunerChannelMapping
- {
- public string Name { get; set; }
-
- public string ProviderChannelName { get; set; }
-
- public string ProviderChannelId { get; set; }
-
- public string Id { get; set; }
- }
-}
diff --git a/MediaBrowser.Controller/Lyrics/ILyricManager.cs b/MediaBrowser.Controller/Lyrics/ILyricManager.cs
index bb93e1e4c..f4376a1ee 100644
--- a/MediaBrowser.Controller/Lyrics/ILyricManager.cs
+++ b/MediaBrowser.Controller/Lyrics/ILyricManager.cs
@@ -1,5 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Lyrics;
+using MediaBrowser.Model.Providers;
namespace MediaBrowser.Controller.Lyrics;
@@ -9,16 +16,93 @@ namespace MediaBrowser.Controller.Lyrics;
public interface ILyricManager
{
/// <summary>
- /// Gets the lyrics.
+ /// Occurs when a lyric download fails.
/// </summary>
- /// <param name="item">The media item.</param>
- /// <returns>A task representing found lyrics the passed item.</returns>
- Task<LyricResponse?> GetLyrics(BaseItem item);
+ event EventHandler<LyricDownloadFailureEventArgs> LyricDownloadFailure;
/// <summary>
- /// Checks if requested item has a matching local lyric file.
+ /// Search for lyrics for the specified song.
/// </summary>
- /// <param name="item">The media item.</param>
- /// <returns>True if item has a matching lyric file; otherwise false.</returns>
- bool HasLyricFile(BaseItem item);
+ /// <param name="audio">The song.</param>
+ /// <param name="isAutomated">Whether the request is automated.</param>
+ /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+ /// <returns>The list of lyrics.</returns>
+ Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(
+ Audio audio,
+ bool isAutomated,
+ CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Search for lyrics.
+ /// </summary>
+ /// <param name="request">The search request.</param>
+ /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+ /// <returns>The list of lyrics.</returns>
+ Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(
+ LyricSearchRequest request,
+ CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Download the lyrics.
+ /// </summary>
+ /// <param name="audio">The audio.</param>
+ /// <param name="lyricId">The remote lyric id.</param>
+ /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+ /// <returns>The downloaded lyrics.</returns>
+ Task<LyricDto?> DownloadLyricsAsync(
+ Audio audio,
+ string lyricId,
+ CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Download the lyrics.
+ /// </summary>
+ /// <param name="audio">The audio.</param>
+ /// <param name="libraryOptions">The library options to use.</param>
+ /// <param name="lyricId">The remote lyric id.</param>
+ /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+ /// <returns>The downloaded lyrics.</returns>
+ Task<LyricDto?> DownloadLyricsAsync(
+ Audio audio,
+ LibraryOptions libraryOptions,
+ string lyricId,
+ CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Upload new lyrics.
+ /// </summary>
+ /// <param name="audio">The audio file the lyrics belong to.</param>
+ /// <param name="lyricResponse">The lyric response.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ Task<LyricDto?> UploadLyricAsync(Audio audio, LyricResponse lyricResponse);
+
+ /// <summary>
+ /// Get the remote lyrics.
+ /// </summary>
+ /// <param name="id">The remote lyrics id.</param>
+ /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+ /// <returns>The lyric response.</returns>
+ Task<LyricDto?> GetRemoteLyricsAsync(string id, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Deletes the lyrics.
+ /// </summary>
+ /// <param name="audio">The audio file to remove lyrics from.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ Task DeleteLyricsAsync(Audio audio);
+
+ /// <summary>
+ /// Get the list of lyric providers.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <returns>Lyric providers.</returns>
+ IReadOnlyList<LyricProviderInfo> GetSupportedProviders(BaseItem item);
+
+ /// <summary>
+ /// Get the existing lyric for the audio.
+ /// </summary>
+ /// <param name="audio">The audio item.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The parsed lyric model.</returns>
+ Task<LyricDto?> GetLyricsAsync(Audio audio, CancellationToken cancellationToken);
}
diff --git a/MediaBrowser.Controller/Lyrics/ILyricParser.cs b/MediaBrowser.Controller/Lyrics/ILyricParser.cs
index 65a9471a3..819950d09 100644
--- a/MediaBrowser.Controller/Lyrics/ILyricParser.cs
+++ b/MediaBrowser.Controller/Lyrics/ILyricParser.cs
@@ -1,5 +1,5 @@
using MediaBrowser.Controller.Resolvers;
-using MediaBrowser.Providers.Lyric;
+using MediaBrowser.Model.Lyrics;
namespace MediaBrowser.Controller.Lyrics;
@@ -24,5 +24,5 @@ public interface ILyricParser
/// </summary>
/// <param name="lyrics">The raw lyrics content.</param>
/// <returns>The parsed lyrics or null if invalid.</returns>
- LyricResponse? ParseLyrics(LyricFile lyrics);
+ LyricDto? ParseLyrics(LyricFile lyrics);
}
diff --git a/MediaBrowser.Controller/Lyrics/ILyricProvider.cs b/MediaBrowser.Controller/Lyrics/ILyricProvider.cs
new file mode 100644
index 000000000..0831a4c4e
--- /dev/null
+++ b/MediaBrowser.Controller/Lyrics/ILyricProvider.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Lyrics;
+using MediaBrowser.Model.Providers;
+
+namespace MediaBrowser.Controller.Lyrics;
+
+/// <summary>
+/// Interface ILyricsProvider.
+/// </summary>
+public interface ILyricProvider
+{
+ /// <summary>
+ /// Gets the provider name.
+ /// </summary>
+ string Name { get; }
+
+ /// <summary>
+ /// Search for lyrics.
+ /// </summary>
+ /// <param name="request">The search request.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The list of remote lyrics.</returns>
+ Task<IEnumerable<RemoteLyricInfo>> SearchAsync(LyricSearchRequest request, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Get the lyrics.
+ /// </summary>
+ /// <param name="id">The remote lyric id.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The lyric response.</returns>
+ Task<LyricResponse?> GetLyricsAsync(string id, CancellationToken cancellationToken);
+}
diff --git a/MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs b/MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs
new file mode 100644
index 000000000..1b1f36020
--- /dev/null
+++ b/MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs
@@ -0,0 +1,26 @@
+using System;
+using MediaBrowser.Controller.Entities;
+
+namespace MediaBrowser.Controller.Lyrics
+{
+ /// <summary>
+ /// An event that occurs when subtitle downloading fails.
+ /// </summary>
+ public class LyricDownloadFailureEventArgs : EventArgs
+ {
+ /// <summary>
+ /// Gets or sets the item.
+ /// </summary>
+ public required BaseItem Item { get; set; }
+
+ /// <summary>
+ /// Gets or sets the provider.
+ /// </summary>
+ public required string Provider { get; set; }
+
+ /// <summary>
+ /// Gets or sets the exception.
+ /// </summary>
+ public required Exception Exception { get; set; }
+ }
+}
diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
index fb4e7bd1f..29dd190ab 100644
--- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
+++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
@@ -88,6 +88,12 @@ namespace MediaBrowser.Controller.MediaEncoding
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>
/// <value>The framerate.</value>
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 400e7f40f..b6738e7cc 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -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;
-
- // setup a relative small canvas_size for overlay_qsv/vaapi to reduce transfer overhead
- var (overlayW, overlayH) = GetFixedOutputSize(inW, inH, reqW, reqH, reqMaxW, 1080);
+ var subtitleWidth = state.SubtitleStream?.Width;
+ var subtitleHeight = state.SubtitleStream?.Height;
- 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,scale,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)
@@ -4899,6 +5007,237 @@ namespace MediaBrowser.Controller.MediaEncoding
}
/// <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>
/// <param name="state">Encoding state.</param>
@@ -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>
@@ -6075,13 +6541,14 @@ namespace MediaBrowser.Controller.MediaEncoding
return " -codec:s:0 " + codec + " -disposition:s:0 default";
}
- public string GetProgressiveVideoFullCommandLine(EncodingJobInfo state, EncodingOptions encodingOptions, string outputPath, string defaultPreset)
+ public string GetProgressiveVideoFullCommandLine(EncodingJobInfo state, EncodingOptions encodingOptions, string defaultPreset)
{
// Get the output codec name
var videoCodec = GetVideoEncoder(state, encodingOptions);
var format = string.Empty;
var keyFrame = string.Empty;
+ var outputPath = state.OutputFilePath;
if (Path.GetExtension(outputPath.AsSpan()).Equals(".mp4", StringComparison.OrdinalIgnoreCase)
&& state.BaseRequest.Context == EncodingContext.Streaming)
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
index 17813559a..f2a0b906d 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
@@ -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))
diff --git a/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs b/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs
index c19a12ae7..09bc01f74 100644
--- a/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs
+++ b/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs
@@ -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);
}
diff --git a/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs b/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs
deleted file mode 100644
index 044ba6d33..000000000
--- a/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs
+++ /dev/null
@@ -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; }
- }
-}
diff --git a/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs b/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs
deleted file mode 100644
index 841e7b287..000000000
--- a/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-#pragma warning disable CS1591
-
-namespace MediaBrowser.Controller.MediaEncoding
-{
- /// <summary>
- /// Class MediaEncoderHelpers.
- /// </summary>
- public static class MediaEncoderHelpers
- {
- }
-}
diff --git a/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs b/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs
deleted file mode 100644
index 2b831103a..000000000
--- a/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs
+++ /dev/null
@@ -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
- {
- }
-}
diff --git a/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs b/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs
deleted file mode 100644
index 6024661e1..000000000
--- a/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs
+++ /dev/null
@@ -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();
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index 299f294b2..ff91a60a7 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -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();
+ }
}
}
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
index 0d1d27ae8..fdca28390 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
@@ -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[]>
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 4dbefca4b..cc6971c1b 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -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;
@@ -1120,6 +1111,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
return allVobs
.Where(vob => titles.Contains(_fileSystem.GetFileNameWithoutExtension(vob).AsSpan().RightPart('_').ToString()))
.Select(i => i.FullName)
+ .Order()
.ToList();
}
@@ -1136,6 +1128,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
return directoryFiles
.Where(f => validPlaybackFiles.Contains(f.Name, StringComparer.OrdinalIgnoreCase))
.Select(f => f.FullName)
+ .Order()
.ToList();
}
@@ -1159,31 +1152,29 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
// Generate concat configuration entries for each file and write to file
- using (StreamWriter sw = new StreamWriter(concatFilePath))
+ using StreamWriter sw = new StreamWriter(concatFilePath);
+ foreach (var path in files)
{
- foreach (var path in files)
- {
- var mediaInfoResult = GetMediaInfo(
- new MediaInfoRequest
+ var mediaInfoResult = GetMediaInfo(
+ new MediaInfoRequest
+ {
+ MediaType = DlnaProfileType.Video,
+ MediaSource = new MediaSourceInfo
{
- MediaType = DlnaProfileType.Video,
- MediaSource = new MediaSourceInfo
- {
- Path = path,
- Protocol = MediaProtocol.File,
- VideoType = videoType
- }
- },
- CancellationToken.None).GetAwaiter().GetResult();
-
- var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds;
-
- // Add file path stanza to concat configuration
- sw.WriteLine("file '{0}'", path);
-
- // Add duration stanza to concat configuration
- sw.WriteLine("duration {0}", duration);
- }
+ Path = path,
+ Protocol = MediaProtocol.File,
+ VideoType = videoType
+ }
+ },
+ CancellationToken.None).GetAwaiter().GetResult();
+
+ var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds;
+
+ // Add file path stanza to concat configuration
+ sw.WriteLine("file '{0}'", path);
+
+ // Add duration stanza to concat configuration
+ sw.WriteLine("duration {0}", duration);
}
}
diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
index a4e8194c1..be63513a7 100644
--- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
+++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
<PropertyGroup>
@@ -22,6 +22,7 @@
</ItemGroup>
<ItemGroup>
+ <PackageReference Include="AsyncKeyedLock" />
<PackageReference Include="BDInfo" />
<PackageReference Include="libse" />
<PackageReference Include="Microsoft.Extensions.Http" />
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 629c30060..317aba418 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -30,6 +30,8 @@ namespace MediaBrowser.MediaEncoding.Probing
private const string ArtistReplaceValue = " | ";
private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' };
+ private readonly string[] _webmVideoCodecs = { "av1", "vp8", "vp9" };
+ private readonly string[] _webmAudioCodecs = { "opus", "vorbis" };
private readonly ILogger _logger;
private readonly ILocalizationManager _localization;
@@ -114,7 +116,7 @@ namespace MediaBrowser.MediaEncoding.Probing
if (data.Format is not null)
{
- info.Container = NormalizeFormat(data.Format.FormatName);
+ info.Container = NormalizeFormat(data.Format.FormatName, info.MediaStreams);
if (int.TryParse(data.Format.BitRate, CultureInfo.InvariantCulture, out var value))
{
@@ -260,7 +262,7 @@ namespace MediaBrowser.MediaEncoding.Probing
return info;
}
- private string NormalizeFormat(string format)
+ private string NormalizeFormat(string format, IReadOnlyList<MediaStream> mediaStreams)
{
if (string.IsNullOrWhiteSpace(format))
{
@@ -288,9 +290,20 @@ namespace MediaBrowser.MediaEncoding.Probing
{
splitFormat[i] = "mkv";
}
+
+ // Handle WebM
+ else if (string.Equals(splitFormat[i], "webm", StringComparison.OrdinalIgnoreCase))
+ {
+ // Limit WebM to supported codecs
+ if (mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))
+ || (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))))
+ {
+ splitFormat[i] = string.Empty;
+ }
+ }
}
- return string.Join(',', splitFormat);
+ return string.Join(',', splitFormat.Where(s => !string.IsNullOrEmpty(s)));
}
private int? GetEstimatedAudioBitrate(string codec, int? channels)
@@ -742,6 +755,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"
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 459d854bf..4b1b1bbc6 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -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,
@@ -318,16 +297,6 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
/// <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>
/// <param name="subtitleStream">The subtitle stream.</param>
@@ -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(
@@ -530,7 +680,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var processArgs = string.Format(
CultureInfo.InvariantCulture,
- "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
+ "-i \"{0}\" -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
inputPath,
subtitleStreamIndex,
outputCodec,
@@ -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
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index d79e4441a..8bace15c6 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -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!))
@@ -404,7 +405,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId);
if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding))
{
- this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
+ OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
throw new ArgumentException("User does not have access to video transcoding.");
}
@@ -416,7 +417,12 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
{
var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
- if (state.VideoType != VideoType.Dvd)
+ if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay)
+ {
+ var concatPath = Path.Join(_serverConfigurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat");
+ await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
+ }
+ else
{
await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
}
@@ -451,7 +457,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
EnableRaisingEvents = true
};
- var transcodingJob = this.OnTranscodeBeginning(
+ var transcodingJob = OnTranscodeBeginning(
outputPath,
state.Request.PlaySessionId,
state.MediaSource.LiveStreamId,
@@ -506,7 +512,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
catch (Exception ex)
{
_logger.LogError(ex, "Error starting FFmpeg");
- this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
+ OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
throw;
}
@@ -625,11 +631,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 +706,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 +728,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();
}
}
diff --git a/MediaBrowser.Model/ClientLog/ClientLogEvent.cs b/MediaBrowser.Model/ClientLog/ClientLogEvent.cs
deleted file mode 100644
index 21087b564..000000000
--- a/MediaBrowser.Model/ClientLog/ClientLogEvent.cs
+++ /dev/null
@@ -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; }
- }
-}
diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs
index 1c071067d..42148a276 100644
--- a/MediaBrowser.Model/Configuration/LibraryOptions.cs
+++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs
@@ -1,6 +1,7 @@
#pragma warning disable CS1591
using System;
+using System.ComponentModel;
namespace MediaBrowser.Model.Configuration
{
@@ -20,6 +21,7 @@ namespace MediaBrowser.Model.Configuration
AutomaticallyAddToCollection = false;
EnablePhotos = true;
SaveSubtitlesWithMedia = true;
+ SaveLyricsWithMedia = true;
PathInfos = Array.Empty<MediaPathInfo>();
EnableAutomaticSeriesGrouping = true;
SeasonZeroDisplayName = "Specials";
@@ -92,6 +94,9 @@ namespace MediaBrowser.Model.Configuration
public bool SaveSubtitlesWithMedia { get; set; }
+ [DefaultValue(true)]
+ public bool SaveLyricsWithMedia { get; set; }
+
public bool AutomaticallyAddToCollection { get; set; }
public EmbeddedSubtitleOptions AllowEmbeddedSubtitles { get; set; }
diff --git a/MediaBrowser.Model/Configuration/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs
index 4c5e95266..ef303726d 100644
--- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs
+++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs
@@ -13,6 +13,7 @@ namespace MediaBrowser.Model.Configuration
LocalMetadataProvider,
MetadataFetcher,
MetadataSaver,
- SubtitleFetcher
+ SubtitleFetcher,
+ LyricFetcher
}
}
diff --git a/MediaBrowser.Model/Dlna/DlnaProfileType.cs b/MediaBrowser.Model/Dlna/DlnaProfileType.cs
index c1a663bf1..1bb885c44 100644
--- a/MediaBrowser.Model/Dlna/DlnaProfileType.cs
+++ b/MediaBrowser.Model/Dlna/DlnaProfileType.cs
@@ -7,6 +7,7 @@ namespace MediaBrowser.Model.Dlna
Audio = 0,
Video = 1,
Photo = 2,
- Subtitle = 3
+ Subtitle = 3,
+ Lyric = 4
}
}
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index da683a17e..e6b7f4d9b 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -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)
diff --git a/MediaBrowser.Model/Dto/ImageByNameInfo.cs b/MediaBrowser.Model/Dto/ImageByNameInfo.cs
deleted file mode 100644
index 06cc3e73c..000000000
--- a/MediaBrowser.Model/Dto/ImageByNameInfo.cs
+++ /dev/null
@@ -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; }
- }
-}
diff --git a/MediaBrowser.Model/Entities/MediaStreamType.cs b/MediaBrowser.Model/Entities/MediaStreamType.cs
index 83751a6a7..0964bb769 100644
--- a/MediaBrowser.Model/Entities/MediaStreamType.cs
+++ b/MediaBrowser.Model/Entities/MediaStreamType.cs
@@ -28,6 +28,11 @@ namespace MediaBrowser.Model.Entities
/// <summary>
/// The data.
/// </summary>
- Data
+ Data,
+
+ /// <summary>
+ /// The lyric.
+ /// </summary>
+ Lyric
}
}
diff --git a/MediaBrowser.Model/Entities/SpecialFolder.cs b/MediaBrowser.Model/Entities/SpecialFolder.cs
deleted file mode 100644
index 2250c5dff..000000000
--- a/MediaBrowser.Model/Entities/SpecialFolder.cs
+++ /dev/null
@@ -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";
- }
-}
diff --git a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs b/MediaBrowser.Model/LiveTv/ChannelMappingOptionsDto.cs
index cbc3548b1..3f9ecc8c8 100644
--- a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs
+++ b/MediaBrowser.Model/LiveTv/ChannelMappingOptionsDto.cs
@@ -1,9 +1,8 @@
using System;
using System.Collections.Generic;
-using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
-namespace Jellyfin.Api.Models.LiveTvDtos;
+namespace MediaBrowser.Model.LiveTv;
/// <summary>
/// Channel mapping options dto.
diff --git a/MediaBrowser.Model/LiveTv/TunerChannelMapping.cs b/MediaBrowser.Model/LiveTv/TunerChannelMapping.cs
new file mode 100644
index 000000000..647e24a91
--- /dev/null
+++ b/MediaBrowser.Model/LiveTv/TunerChannelMapping.cs
@@ -0,0 +1,16 @@
+#nullable disable
+
+#pragma warning disable CS1591
+
+namespace MediaBrowser.Model.LiveTv;
+
+public class TunerChannelMapping
+{
+ public string Name { get; set; }
+
+ public string ProviderChannelName { get; set; }
+
+ public string ProviderChannelId { get; set; }
+
+ public string Id { get; set; }
+}
diff --git a/MediaBrowser.Controller/Lyrics/LyricResponse.cs b/MediaBrowser.Model/Lyrics/LyricDto.cs
index 0d52b5ec5..7a9bffc99 100644
--- a/MediaBrowser.Controller/Lyrics/LyricResponse.cs
+++ b/MediaBrowser.Model/Lyrics/LyricDto.cs
@@ -1,12 +1,11 @@
-using System;
using System.Collections.Generic;
-namespace MediaBrowser.Controller.Lyrics;
+namespace MediaBrowser.Model.Lyrics;
/// <summary>
/// LyricResponse model.
/// </summary>
-public class LyricResponse
+public class LyricDto
{
/// <summary>
/// Gets or sets Metadata for the lyrics.
@@ -16,5 +15,5 @@ public class LyricResponse
/// <summary>
/// Gets or sets a collection of individual lyric lines.
/// </summary>
- public IReadOnlyList<LyricLine> Lyrics { get; set; } = Array.Empty<LyricLine>();
+ public IReadOnlyList<LyricLine> Lyrics { get; set; } = [];
}
diff --git a/MediaBrowser.Controller/Lyrics/LyricFile.cs b/MediaBrowser.Model/Lyrics/LyricFile.cs
index ede89403c..3912b037e 100644
--- a/MediaBrowser.Controller/Lyrics/LyricFile.cs
+++ b/MediaBrowser.Model/Lyrics/LyricFile.cs
@@ -1,4 +1,4 @@
-namespace MediaBrowser.Providers.Lyric;
+namespace MediaBrowser.Model.Lyrics;
/// <summary>
/// The information for a raw lyrics file before parsing.
diff --git a/MediaBrowser.Controller/Lyrics/LyricLine.cs b/MediaBrowser.Model/Lyrics/LyricLine.cs
index c406f92fc..64d1f64c2 100644
--- a/MediaBrowser.Controller/Lyrics/LyricLine.cs
+++ b/MediaBrowser.Model/Lyrics/LyricLine.cs
@@ -1,4 +1,4 @@
-namespace MediaBrowser.Controller.Lyrics;
+namespace MediaBrowser.Model.Lyrics;
/// <summary>
/// Lyric model.
diff --git a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs b/MediaBrowser.Model/Lyrics/LyricMetadata.cs
index c4f033489..4f819d6c9 100644
--- a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs
+++ b/MediaBrowser.Model/Lyrics/LyricMetadata.cs
@@ -1,4 +1,4 @@
-namespace MediaBrowser.Controller.Lyrics;
+namespace MediaBrowser.Model.Lyrics;
/// <summary>
/// LyricMetadata model.
@@ -49,4 +49,9 @@ public class LyricMetadata
/// Gets or sets the version of the creator used.
/// </summary>
public string? Version { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this lyric is synced.
+ /// </summary>
+ public bool? IsSynced { get; set; }
}
diff --git a/MediaBrowser.Model/Lyrics/LyricResponse.cs b/MediaBrowser.Model/Lyrics/LyricResponse.cs
new file mode 100644
index 000000000..b04adeb7b
--- /dev/null
+++ b/MediaBrowser.Model/Lyrics/LyricResponse.cs
@@ -0,0 +1,19 @@
+using System.IO;
+
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// LyricResponse model.
+/// </summary>
+public class LyricResponse
+{
+ /// <summary>
+ /// Gets or sets the lyric stream.
+ /// </summary>
+ public required Stream Stream { get; set; }
+
+ /// <summary>
+ /// Gets or sets the lyric format.
+ /// </summary>
+ public required string Format { get; set; }
+}
diff --git a/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs b/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs
new file mode 100644
index 000000000..48c442a55
--- /dev/null
+++ b/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Model.Entities;
+
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// Lyric search request.
+/// </summary>
+public class LyricSearchRequest : IHasProviderIds
+{
+ /// <summary>
+ /// Gets or sets the media path.
+ /// </summary>
+ public string? MediaPath { get; set; }
+
+ /// <summary>
+ /// Gets or sets the artist name.
+ /// </summary>
+ public IReadOnlyList<string>? ArtistNames { get; set; }
+
+ /// <summary>
+ /// Gets or sets the album name.
+ /// </summary>
+ public string? AlbumName { get; set; }
+
+ /// <summary>
+ /// Gets or sets the song name.
+ /// </summary>
+ public string? SongName { get; set; }
+
+ /// <summary>
+ /// Gets or sets the track duration in ticks.
+ /// </summary>
+ public long? Duration { get; set; }
+
+ /// <inheritdoc />
+ public Dictionary<string, string> ProviderIds { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to search all providers.
+ /// </summary>
+ public bool SearchAllProviders { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets the list of disabled lyric fetcher names.
+ /// </summary>
+ public IReadOnlyList<string> DisabledLyricFetchers { get; set; } = [];
+
+ /// <summary>
+ /// Gets or sets the order of lyric fetchers.
+ /// </summary>
+ public IReadOnlyList<string> LyricFetcherOrder { get; set; } = [];
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this request is automated.
+ /// </summary>
+ public bool IsAutomated { get; set; }
+}
diff --git a/MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs b/MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs
new file mode 100644
index 000000000..dda56d198
--- /dev/null
+++ b/MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs
@@ -0,0 +1,22 @@
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// The remote lyric info dto.
+/// </summary>
+public class RemoteLyricInfoDto
+{
+ /// <summary>
+ /// Gets or sets the id for the lyric.
+ /// </summary>
+ public required string Id { get; set; }
+
+ /// <summary>
+ /// Gets the provider name.
+ /// </summary>
+ public required string ProviderName { get; init; }
+
+ /// <summary>
+ /// Gets the lyrics.
+ /// </summary>
+ public required LyricDto Lyrics { get; init; }
+}
diff --git a/MediaBrowser.Model/Lyrics/UploadLyricDto.cs b/MediaBrowser.Model/Lyrics/UploadLyricDto.cs
new file mode 100644
index 000000000..0ea8a4c63
--- /dev/null
+++ b/MediaBrowser.Model/Lyrics/UploadLyricDto.cs
@@ -0,0 +1,16 @@
+using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Http;
+
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// Upload lyric dto.
+/// </summary>
+public class UploadLyricDto
+{
+ /// <summary>
+ /// Gets or sets the lyrics file.
+ /// </summary>
+ [Required]
+ public IFormFile Lyrics { get; set; } = null!;
+}
diff --git a/MediaBrowser.Model/Net/MimeTypes.cs b/MediaBrowser.Model/Net/MimeTypes.cs
index 5a1871070..90035f18f 100644
--- a/MediaBrowser.Model/Net/MimeTypes.cs
+++ b/MediaBrowser.Model/Net/MimeTypes.cs
@@ -66,6 +66,11 @@ namespace MediaBrowser.Model.Net
{
// Type application
{ ".azw3", "application/vnd.amazon.ebook" },
+ { ".cb7", "application/x-cb7" },
+ { ".cba", "application/x-cba" },
+ { ".cbr", "application/vnd.comicbook-rar" },
+ { ".cbt", "application/x-cbt" },
+ { ".cbz", "application/vnd.comicbook+zip" },
// Type image
{ ".tbn", "image/jpeg" },
@@ -87,7 +92,7 @@ namespace MediaBrowser.Model.Net
{ ".dsf", "audio/dsf" },
{ ".dsp", "audio/dsp" },
{ ".flac", "audio/flac" },
- { ".m4b", "audio/m4b" },
+ { ".m4b", "audio/mp4" },
{ ".mp3", "audio/mpeg" },
{ ".vorbis", "audio/vorbis" },
{ ".webma", "audio/webm" },
@@ -98,6 +103,12 @@ namespace MediaBrowser.Model.Net
private static readonly Dictionary<string, string> _extensionLookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
// Type application
+ { "application/vnd.comicbook-rar", ".cbr" },
+ { "application/vnd.comicbook+zip", ".cbz" },
+ { "application/x-cb7", ".cb7" },
+ { "application/x-cba", ".cba" },
+ { "application/x-cbr", ".cbr" },
+ { "application/x-cbt", ".cbt" },
{ "application/x-cbz", ".cbz" },
{ "application/x-javascript", ".js" },
{ "application/xml", ".xml" },
diff --git a/MediaBrowser.Model/Net/SocketReceiveResult.cs b/MediaBrowser.Model/Net/SocketReceiveResult.cs
deleted file mode 100644
index 1524786ea..000000000
--- a/MediaBrowser.Model/Net/SocketReceiveResult.cs
+++ /dev/null
@@ -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; }
- }
-}
diff --git a/MediaBrowser.Model/Providers/LyricProviderInfo.cs b/MediaBrowser.Model/Providers/LyricProviderInfo.cs
new file mode 100644
index 000000000..ea9c94185
--- /dev/null
+++ b/MediaBrowser.Model/Providers/LyricProviderInfo.cs
@@ -0,0 +1,17 @@
+namespace MediaBrowser.Model.Providers;
+
+/// <summary>
+/// Lyric provider info.
+/// </summary>
+public class LyricProviderInfo
+{
+ /// <summary>
+ /// Gets the provider name.
+ /// </summary>
+ public required string Name { get; init; }
+
+ /// <summary>
+ /// Gets the provider id.
+ /// </summary>
+ public required string Id { get; init; }
+}
diff --git a/MediaBrowser.Model/Providers/RemoteLyricInfo.cs b/MediaBrowser.Model/Providers/RemoteLyricInfo.cs
new file mode 100644
index 000000000..9fb340a58
--- /dev/null
+++ b/MediaBrowser.Model/Providers/RemoteLyricInfo.cs
@@ -0,0 +1,29 @@
+using MediaBrowser.Model.Lyrics;
+
+namespace MediaBrowser.Model.Providers;
+
+/// <summary>
+/// The remote lyric info.
+/// </summary>
+public class RemoteLyricInfo
+{
+ /// <summary>
+ /// Gets or sets the id for the lyric.
+ /// </summary>
+ public required string Id { get; set; }
+
+ /// <summary>
+ /// Gets the provider name.
+ /// </summary>
+ public required string ProviderName { get; init; }
+
+ /// <summary>
+ /// Gets the lyric metadata.
+ /// </summary>
+ public required LyricMetadata Metadata { get; init; }
+
+ /// <summary>
+ /// Gets the lyrics.
+ /// </summary>
+ public required LyricResponse Lyrics { get; init; }
+}
diff --git a/MediaBrowser.Model/Session/ClientCapabilities.cs b/MediaBrowser.Model/Session/ClientCapabilities.cs
index 597845fc1..5f51fb21c 100644
--- a/MediaBrowser.Model/Session/ClientCapabilities.cs
+++ b/MediaBrowser.Model/Session/ClientCapabilities.cs
@@ -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; }
}
}
diff --git a/MediaBrowser.Model/Session/GeneralCommandType.cs b/MediaBrowser.Model/Session/GeneralCommandType.cs
index 166a6b441..09339928c 100644
--- a/MediaBrowser.Model/Session/GeneralCommandType.cs
+++ b/MediaBrowser.Model/Session/GeneralCommandType.cs
@@ -48,6 +48,7 @@ namespace MediaBrowser.Model.Session
PlayNext = 38,
ToggleOsdMenu = 39,
Play = 40,
- SetMaxStreamingBitrate = 41
+ SetMaxStreamingBitrate = 41,
+ SetPlaybackOrder = 42
}
}
diff --git a/MediaBrowser.Model/Session/HardwareEncodingType.cs b/MediaBrowser.Model/Session/HardwareEncodingType.cs
index f5753467a..058875cd3 100644
--- a/MediaBrowser.Model/Session/HardwareEncodingType.cs
+++ b/MediaBrowser.Model/Session/HardwareEncodingType.cs
@@ -33,6 +33,11 @@
/// <summary>
/// Video ToolBox.
/// </summary>
- VideoToolBox = 5
+ VideoToolBox = 5,
+
+ /// <summary>
+ /// Rockchip Media Process Platform (RKMPP).
+ /// </summary>
+ RKMPP = 6
}
}
diff --git a/MediaBrowser.Model/Session/PlaybackOrder.cs b/MediaBrowser.Model/Session/PlaybackOrder.cs
new file mode 100644
index 000000000..8ef7faf14
--- /dev/null
+++ b/MediaBrowser.Model/Session/PlaybackOrder.cs
@@ -0,0 +1,18 @@
+namespace MediaBrowser.Model.Session
+{
+ /// <summary>
+ /// Enum PlaybackOrder.
+ /// </summary>
+ public enum PlaybackOrder
+ {
+ /// <summary>
+ /// Sorted playlist.
+ /// </summary>
+ Default = 0,
+
+ /// <summary>
+ /// Shuffled playlist.
+ /// </summary>
+ Shuffle = 1
+ }
+}
diff --git a/MediaBrowser.Model/Session/PlaybackProgressInfo.cs b/MediaBrowser.Model/Session/PlaybackProgressInfo.cs
index a6e7efcb0..04a9d6867 100644
--- a/MediaBrowser.Model/Session/PlaybackProgressInfo.cs
+++ b/MediaBrowser.Model/Session/PlaybackProgressInfo.cs
@@ -107,6 +107,12 @@ namespace MediaBrowser.Model.Session
/// <value>The repeat mode.</value>
public RepeatMode RepeatMode { get; set; }
+ /// <summary>
+ /// Gets or sets the playback order.
+ /// </summary>
+ /// <value>The playback order.</value>
+ public PlaybackOrder PlaybackOrder { get; set; }
+
public QueueItem[] NowPlayingQueue { get; set; }
public string PlaylistItemId { get; set; }
diff --git a/MediaBrowser.Model/Session/PlayerStateInfo.cs b/MediaBrowser.Model/Session/PlayerStateInfo.cs
index 80e6d4e0b..35cd68fd1 100644
--- a/MediaBrowser.Model/Session/PlayerStateInfo.cs
+++ b/MediaBrowser.Model/Session/PlayerStateInfo.cs
@@ -66,6 +66,12 @@ namespace MediaBrowser.Model.Session
public RepeatMode RepeatMode { get; set; }
/// <summary>
+ /// Gets or sets the playback order.
+ /// </summary>
+ /// <value>The playback order.</value>
+ public PlaybackOrder PlaybackOrder { get; set; }
+
+ /// <summary>
/// Gets or sets the now playing live stream identifier.
/// </summary>
/// <value>The live stream identifier.</value>
diff --git a/MediaBrowser.Model/System/SystemInfo.cs b/MediaBrowser.Model/System/SystemInfo.cs
index aa7c03ebd..f37ac6a14 100644
--- a/MediaBrowser.Model/System/SystemInfo.cs
+++ b/MediaBrowser.Model/System/SystemInfo.cs
@@ -3,30 +3,12 @@
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>
public class SystemInfo : PublicSystemInfo
@@ -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";
}
}
diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs
index 219ed5d5f..951e05763 100644
--- a/MediaBrowser.Model/Users/UserPolicy.cs
+++ b/MediaBrowser.Model/Users/UserPolicy.cs
@@ -93,6 +93,12 @@ namespace MediaBrowser.Model.Users
public bool EnableSubtitleManagement { get; set; }
/// <summary>
+ /// Gets or sets a value indicating whether this user can manage lyrics.
+ /// </summary>
+ [DefaultValue(false)]
+ public bool EnableLyricManagement { get; set; }
+
+ /// <summary>
/// Gets or sets a value indicating whether this instance is disabled.
/// </summary>
/// <value><c>true</c> if this instance is disabled; otherwise, <c>false</c>.</value>
diff --git a/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs b/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs
deleted file mode 100644
index ab09f278a..000000000
--- a/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-using System;
-using System.IO;
-using System.Threading.Tasks;
-using Jellyfin.Extensions;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Resolvers;
-
-namespace MediaBrowser.Providers.Lyric;
-
-/// <inheritdoc />
-public class DefaultLyricProvider : ILyricProvider
-{
- private static readonly string[] _lyricExtensions = { ".lrc", ".elrc", ".txt" };
-
- /// <inheritdoc />
- public string Name => "DefaultLyricProvider";
-
- /// <inheritdoc />
- public ResolverPriority Priority => ResolverPriority.First;
-
- /// <inheritdoc />
- public bool HasLyrics(BaseItem item)
- {
- var path = GetLyricsPath(item);
- return path is not null;
- }
-
- /// <inheritdoc />
- public async Task<LyricFile?> GetLyrics(BaseItem item)
- {
- var path = GetLyricsPath(item);
- if (path is not null)
- {
- var content = await File.ReadAllTextAsync(path).ConfigureAwait(false);
- if (!string.IsNullOrEmpty(content))
- {
- return new LyricFile(path, content);
- }
- }
-
- return null;
- }
-
- private string? GetLyricsPath(BaseItem item)
- {
- // Ensure the path to the item is not null
- string? itemDirectoryPath = Path.GetDirectoryName(item.Path);
- if (itemDirectoryPath is null)
- {
- return null;
- }
-
- // Ensure the directory path exists
- if (!Directory.Exists(itemDirectoryPath))
- {
- return null;
- }
-
- foreach (var lyricFilePath in Directory.GetFiles(itemDirectoryPath, $"{Path.GetFileNameWithoutExtension(item.Path)}.*"))
- {
- if (_lyricExtensions.Contains(Path.GetExtension(lyricFilePath.AsSpan()), StringComparison.OrdinalIgnoreCase))
- {
- return lyricFilePath;
- }
- }
-
- return null;
- }
-}
diff --git a/MediaBrowser.Providers/Lyric/ILyricProvider.cs b/MediaBrowser.Providers/Lyric/ILyricProvider.cs
deleted file mode 100644
index 27ceba72b..000000000
--- a/MediaBrowser.Providers/Lyric/ILyricProvider.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Resolvers;
-
-namespace MediaBrowser.Providers.Lyric;
-
-/// <summary>
-/// Interface ILyricsProvider.
-/// </summary>
-public interface ILyricProvider
-{
- /// <summary>
- /// Gets a value indicating the provider name.
- /// </summary>
- string Name { get; }
-
- /// <summary>
- /// Gets the priority.
- /// </summary>
- /// <value>The priority.</value>
- ResolverPriority Priority { get; }
-
- /// <summary>
- /// Checks if an item has lyrics available.
- /// </summary>
- /// <param name="item">The media item.</param>
- /// <returns>Whether lyrics where found or not.</returns>
- bool HasLyrics(BaseItem item);
-
- /// <summary>
- /// Gets the lyrics.
- /// </summary>
- /// <param name="item">The media item.</param>
- /// <returns>A task representing found lyrics.</returns>
- Task<LyricFile?> GetLyrics(BaseItem item);
-}
diff --git a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs
index a10ff198b..67b26e457 100644
--- a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs
+++ b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs
@@ -8,6 +8,7 @@ using LrcParser.Model;
using LrcParser.Parser;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Lyrics;
namespace MediaBrowser.Providers.Lyric;
@@ -18,8 +19,8 @@ public class LrcLyricParser : ILyricParser
{
private readonly LyricParser _lrcLyricParser;
- private static readonly string[] _supportedMediaTypes = { ".lrc", ".elrc" };
- private static readonly string[] _acceptedTimeFormats = { "HH:mm:ss", "H:mm:ss", "mm:ss", "m:ss" };
+ private static readonly string[] _supportedMediaTypes = [".lrc", ".elrc"];
+ private static readonly string[] _acceptedTimeFormats = ["HH:mm:ss", "H:mm:ss", "mm:ss", "m:ss"];
/// <summary>
/// Initializes a new instance of the <see cref="LrcLyricParser"/> class.
@@ -39,7 +40,7 @@ public class LrcLyricParser : ILyricParser
public ResolverPriority Priority => ResolverPriority.Fourth;
/// <inheritdoc />
- public LyricResponse? ParseLyrics(LyricFile lyrics)
+ public LyricDto? ParseLyrics(LyricFile lyrics)
{
if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase))
{
@@ -95,7 +96,7 @@ public class LrcLyricParser : ILyricParser
return null;
}
- List<LyricLine> lyricList = new();
+ List<LyricLine> lyricList = [];
for (int i = 0; i < sortedLyricData.Count; i++)
{
@@ -106,7 +107,7 @@ public class LrcLyricParser : ILyricParser
}
long ticks = TimeSpan.FromMilliseconds(timeData.Value).Ticks;
- lyricList.Add(new LyricLine(sortedLyricData[i].Text, ticks));
+ lyricList.Add(new LyricLine(sortedLyricData[i].Text.Trim(), ticks));
}
if (fileMetaData.Count != 0)
@@ -114,10 +115,10 @@ public class LrcLyricParser : ILyricParser
// Map metaData values from LRC file to LyricMetadata properties
LyricMetadata lyricMetadata = MapMetadataValues(fileMetaData);
- return new LyricResponse { Metadata = lyricMetadata, Lyrics = lyricList };
+ return new LyricDto { Metadata = lyricMetadata, Lyrics = lyricList };
}
- return new LyricResponse { Lyrics = lyricList };
+ return new LyricDto { Lyrics = lyricList };
}
/// <summary>
diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs
index 6da811927..60734b89a 100644
--- a/MediaBrowser.Providers/Lyric/LyricManager.cs
+++ b/MediaBrowser.Providers/Lyric/LyricManager.cs
@@ -1,8 +1,25 @@
+using System;
using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
using System.Linq;
+using System.Text;
+using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Extensions;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Lyrics;
+using MediaBrowser.Model.Providers;
+using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Lyric;
@@ -11,37 +28,246 @@ namespace MediaBrowser.Providers.Lyric;
/// </summary>
public class LyricManager : ILyricManager
{
+ private readonly ILogger<LyricManager> _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILibraryMonitor _libraryMonitor;
+ private readonly IMediaSourceManager _mediaSourceManager;
+
private readonly ILyricProvider[] _lyricProviders;
private readonly ILyricParser[] _lyricParsers;
/// <summary>
/// Initializes a new instance of the <see cref="LyricManager"/> class.
/// </summary>
- /// <param name="lyricProviders">All found lyricProviders.</param>
- /// <param name="lyricParsers">All found lyricParsers.</param>
- public LyricManager(IEnumerable<ILyricProvider> lyricProviders, IEnumerable<ILyricParser> lyricParsers)
+ /// <param name="logger">Instance of the <see cref="ILogger{LyricManager}"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
+ /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param>
+ /// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param>
+ public LyricManager(
+ ILogger<LyricManager> logger,
+ IFileSystem fileSystem,
+ ILibraryMonitor libraryMonitor,
+ IMediaSourceManager mediaSourceManager,
+ IEnumerable<ILyricProvider> lyricProviders,
+ IEnumerable<ILyricParser> lyricParsers)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _libraryMonitor = libraryMonitor;
+ _mediaSourceManager = mediaSourceManager;
+ _lyricProviders = lyricProviders
+ .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
+ .ToArray();
+ _lyricParsers = lyricParsers
+ .OrderBy(l => l.Priority)
+ .ToArray();
+ }
+
+ /// <inheritdoc />
+ public event EventHandler<LyricDownloadFailureEventArgs>? LyricDownloadFailure;
+
+ /// <inheritdoc />
+ public Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(audio);
+
+ var request = new LyricSearchRequest
+ {
+ MediaPath = audio.Path,
+ SongName = audio.Name,
+ AlbumName = audio.Album,
+ ArtistNames = audio.GetAllArtists().ToList(),
+ Duration = audio.RunTimeTicks,
+ IsAutomated = isAutomated
+ };
+
+ return SearchLyricsAsync(request, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public async Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(LyricSearchRequest request, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var providers = _lyricProviders
+ .Where(i => !request.DisabledLyricFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase))
+ .OrderBy(i =>
+ {
+ var index = request.LyricFetcherOrder.IndexOf(i.Name);
+ return index == -1 ? int.MaxValue : index;
+ })
+ .ToArray();
+
+ // If not searching all, search one at a time until something is found
+ if (!request.SearchAllProviders)
+ {
+ foreach (var provider in providers)
+ {
+ var providerResult = await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false);
+ if (providerResult.Count > 0)
+ {
+ return providerResult;
+ }
+ }
+
+ return [];
+ }
+
+ var tasks = providers.Select(async provider => await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false));
+
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ return results.SelectMany(i => i).ToArray();
+ }
+
+ /// <inheritdoc />
+ public Task<LyricDto?> DownloadLyricsAsync(Audio audio, string lyricId, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(audio);
+ ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
+
+ var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
+
+ return DownloadLyricsAsync(audio, libraryOptions, lyricId, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public async Task<LyricDto?> DownloadLyricsAsync(Audio audio, LibraryOptions libraryOptions, string lyricId, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(audio);
+ ArgumentNullException.ThrowIfNull(libraryOptions);
+ ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
+
+ var provider = GetProvider(lyricId.AsSpan().LeftPart('_').ToString());
+ if (provider is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ var response = await InternalGetRemoteLyricsAsync(lyricId, cancellationToken).ConfigureAwait(false);
+ if (response is null)
+ {
+ _logger.LogDebug("Unable to download lyrics for {LyricId}", lyricId);
+ return null;
+ }
+
+ var parsedLyrics = await InternalParseRemoteLyricsAsync(response, cancellationToken).ConfigureAwait(false);
+ if (parsedLyrics is null)
+ {
+ return null;
+ }
+
+ await TrySaveLyric(audio, libraryOptions, response).ConfigureAwait(false);
+ return parsedLyrics;
+ }
+ catch (RateLimitExceededException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ LyricDownloadFailure?.Invoke(this, new LyricDownloadFailureEventArgs
+ {
+ Item = audio,
+ Exception = ex,
+ Provider = provider.Name
+ });
+
+ throw;
+ }
+ }
+
+ /// <inheritdoc />
+ public async Task<LyricDto?> UploadLyricAsync(Audio audio, LyricResponse lyricResponse)
{
- _lyricProviders = lyricProviders.OrderBy(i => i.Priority).ToArray();
- _lyricParsers = lyricParsers.OrderBy(i => i.Priority).ToArray();
+ ArgumentNullException.ThrowIfNull(audio);
+ ArgumentNullException.ThrowIfNull(lyricResponse);
+ var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
+
+ var parsed = await InternalParseRemoteLyricsAsync(lyricResponse, CancellationToken.None).ConfigureAwait(false);
+ if (parsed is null)
+ {
+ return null;
+ }
+
+ await TrySaveLyric(audio, libraryOptions, lyricResponse).ConfigureAwait(false);
+ return parsed;
+ }
+
+ /// <inheritdoc />
+ public async Task<LyricDto?> GetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(id);
+
+ var lyricResponse = await InternalGetRemoteLyricsAsync(id, cancellationToken).ConfigureAwait(false);
+ if (lyricResponse is null)
+ {
+ return null;
+ }
+
+ return await InternalParseRemoteLyricsAsync(lyricResponse, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
- public async Task<LyricResponse?> GetLyrics(BaseItem item)
+ public Task DeleteLyricsAsync(Audio audio)
{
- foreach (ILyricProvider provider in _lyricProviders)
+ ArgumentNullException.ThrowIfNull(audio);
+ var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
+ {
+ ItemId = audio.Id,
+ Type = MediaStreamType.Lyric
+ });
+
+ foreach (var stream in streams)
{
- var lyrics = await provider.GetLyrics(item).ConfigureAwait(false);
- if (lyrics is null)
+ var path = stream.Path;
+ _libraryMonitor.ReportFileSystemChangeBeginning(path);
+
+ try
{
- continue;
+ _fileSystem.DeleteFile(path);
}
+ finally
+ {
+ _libraryMonitor.ReportFileSystemChangeComplete(path, false);
+ }
+ }
+
+ return audio.RefreshMetadata(CancellationToken.None);
+ }
+
+ /// <inheritdoc />
+ public IReadOnlyList<LyricProviderInfo> GetSupportedProviders(BaseItem item)
+ {
+ if (item is not Audio)
+ {
+ return [];
+ }
+
+ return _lyricProviders.Select(p => new LyricProviderInfo { Name = p.Name, Id = GetProviderId(p.Name) }).ToList();
+ }
- foreach (ILyricParser parser in _lyricParsers)
+ /// <inheritdoc />
+ public async Task<LyricDto?> GetLyricsAsync(Audio audio, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(audio);
+
+ var lyricStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric);
+ foreach (var lyricStream in lyricStreams)
+ {
+ var lyricContents = await File.ReadAllTextAsync(lyricStream.Path, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
+
+ var lyricFile = new LyricFile(Path.GetFileName(lyricStream.Path), lyricContents);
+ foreach (var parser in _lyricParsers)
{
- var result = parser.ParseLyrics(lyrics);
- if (result is not null)
+ var parsedLyrics = parser.ParseLyrics(lyricFile);
+ if (parsedLyrics is not null)
{
- return result;
+ return parsedLyrics;
}
}
}
@@ -49,22 +275,180 @@ public class LyricManager : ILyricManager
return null;
}
- /// <inheritdoc />
- public bool HasLyricFile(BaseItem item)
+ private ILyricProvider? GetProvider(string providerId)
+ {
+ var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringComparison.Ordinal));
+ if (provider is null)
+ {
+ _logger.LogWarning("Unknown provider id: {ProviderId}", providerId.ReplaceLineEndings(string.Empty));
+ }
+
+ return provider;
+ }
+
+ private string GetProviderId(string name)
+ => name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
+
+ private async Task<LyricDto?> InternalParseRemoteLyricsAsync(LyricResponse lyricResponse, CancellationToken cancellationToken)
+ {
+ lyricResponse.Stream.Seek(0, SeekOrigin.Begin);
+ using var streamReader = new StreamReader(lyricResponse.Stream, leaveOpen: true);
+ var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ var lyricFile = new LyricFile($"lyric.{lyricResponse.Format}", lyrics);
+ foreach (var parser in _lyricParsers)
+ {
+ var parsedLyrics = parser.ParseLyrics(lyricFile);
+ if (parsedLyrics is not null)
+ {
+ return parsedLyrics;
+ }
+ }
+
+ return null;
+ }
+
+ private async Task<LyricResponse?> InternalGetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(id);
+ var parts = id.Split('_', 2);
+ var provider = GetProvider(parts[0]);
+ if (provider is null)
+ {
+ return null;
+ }
+
+ id = parts[^1];
+
+ return await provider.GetLyricsAsync(id, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task<IReadOnlyList<RemoteLyricInfoDto>> InternalSearchProviderAsync(
+ ILyricProvider provider,
+ LyricSearchRequest request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var providerId = GetProviderId(provider.Name);
+ var searchResults = await provider.SearchAsync(request, cancellationToken).ConfigureAwait(false);
+ var parsedResults = new List<RemoteLyricInfoDto>();
+ foreach (var result in searchResults)
+ {
+ var parsedLyrics = await InternalParseRemoteLyricsAsync(result.Lyrics, cancellationToken).ConfigureAwait(false);
+ if (parsedLyrics is null)
+ {
+ continue;
+ }
+
+ parsedLyrics.Metadata = result.Metadata;
+ parsedResults.Add(new RemoteLyricInfoDto
+ {
+ Id = $"{providerId}_{result.Id}",
+ ProviderName = result.ProviderName,
+ Lyrics = parsedLyrics
+ });
+ }
+
+ return parsedResults;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error downloading lyrics from {Provider}", provider.Name);
+ return [];
+ }
+ }
+
+ private async Task TrySaveLyric(
+ Audio audio,
+ LibraryOptions libraryOptions,
+ LyricResponse lyricResponse)
{
- foreach (ILyricProvider provider in _lyricProviders)
+ var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia;
+
+ var memoryStream = new MemoryStream();
+ await using (memoryStream.ConfigureAwait(false))
{
- if (item is null)
+ var stream = lyricResponse.Stream;
+
+ await using (stream.ConfigureAwait(false))
{
- continue;
+ stream.Seek(0, SeekOrigin.Begin);
+ await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
+ memoryStream.Seek(0, SeekOrigin.Begin);
}
- if (provider.HasLyrics(item))
+ var savePaths = new List<string>();
+ var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + lyricResponse.Format.ReplaceLineEndings(string.Empty).ToLowerInvariant();
+
+ if (saveInMediaFolder)
{
- return true;
+ var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
+ // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
+ if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
+ {
+ savePaths.Add(mediaFolderPath);
+ }
+ }
+
+ var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
+
+ // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
+ if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
+ {
+ savePaths.Add(internalPath);
+ }
+
+ if (savePaths.Count > 0)
+ {
+ await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
+ }
+ else
+ {
+ _logger.LogError("An uploaded lyric could not be saved because the resulting paths were invalid.");
}
}
+ }
- return false;
+ private async Task TrySaveToFiles(Stream stream, List<string> savePaths)
+ {
+ List<Exception>? exs = null;
+
+ foreach (var savePath in savePaths)
+ {
+ _logger.LogInformation("Saving lyrics to {SavePath}", savePath.ReplaceLineEndings(string.Empty));
+
+ _libraryMonitor.ReportFileSystemChangeBeginning(savePath);
+
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path can't be a root directory."));
+
+ var fileOptions = AsyncFile.WriteOptions;
+ fileOptions.Mode = FileMode.Create;
+ fileOptions.PreallocationSize = stream.Length;
+ var fs = new FileStream(savePath, fileOptions);
+ await using (fs.ConfigureAwait(false))
+ {
+ await stream.CopyToAsync(fs).ConfigureAwait(false);
+ }
+
+ return;
+ }
+ catch (Exception ex)
+ {
+ (exs ??= []).Add(ex);
+ }
+ finally
+ {
+ _libraryMonitor.ReportFileSystemChangeComplete(savePath, false);
+ }
+
+ stream.Position = 0;
+ }
+
+ if (exs is not null)
+ {
+ throw new AggregateException(exs);
+ }
}
}
diff --git a/MediaBrowser.Providers/Lyric/TxtLyricParser.cs b/MediaBrowser.Providers/Lyric/TxtLyricParser.cs
index 706f13dbc..a8188da28 100644
--- a/MediaBrowser.Providers/Lyric/TxtLyricParser.cs
+++ b/MediaBrowser.Providers/Lyric/TxtLyricParser.cs
@@ -3,6 +3,7 @@ using System.IO;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Lyrics;
namespace MediaBrowser.Providers.Lyric;
@@ -11,8 +12,8 @@ namespace MediaBrowser.Providers.Lyric;
/// </summary>
public class TxtLyricParser : ILyricParser
{
- private static readonly string[] _supportedMediaTypes = { ".lrc", ".elrc", ".txt" };
- private static readonly string[] _lineBreakCharacters = { "\r\n", "\r", "\n" };
+ private static readonly string[] _supportedMediaTypes = [".lrc", ".elrc", ".txt"];
+ private static readonly string[] _lineBreakCharacters = ["\r\n", "\r", "\n"];
/// <inheritdoc />
public string Name => "TxtLyricProvider";
@@ -24,7 +25,7 @@ public class TxtLyricParser : ILyricParser
public ResolverPriority Priority => ResolverPriority.Fifth;
/// <inheritdoc />
- public LyricResponse? ParseLyrics(LyricFile lyrics)
+ public LyricDto? ParseLyrics(LyricFile lyrics)
{
if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase))
{
@@ -36,9 +37,9 @@ public class TxtLyricParser : ILyricParser
for (int lyricLineIndex = 0; lyricLineIndex < lyricTextLines.Length; lyricLineIndex++)
{
- lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex]);
+ lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex].Trim());
}
- return new LyricResponse { Lyrics = lyricList };
+ return new LyricDto { Lyrics = lyricList };
}
}
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index b530b9de3..81a299015 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -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;
@@ -22,6 +21,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Configuration;
@@ -53,6 +53,7 @@ namespace MediaBrowser.Providers.Manager
private readonly IServerApplicationPaths _appPaths;
private readonly ILibraryManager _libraryManager;
private readonly ISubtitleManager _subtitleManager;
+ private readonly ILyricManager _lyricManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly IBaseItemManager _baseItemManager;
private readonly ConcurrentDictionary<Guid, double> _activeRefreshes = new();
@@ -79,6 +80,7 @@ namespace MediaBrowser.Providers.Manager
/// <param name="appPaths">The server application paths.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="baseItemManager">The BaseItem manager.</param>
+ /// <param name="lyricManager">The lyric manager.</param>
public ProviderManager(
IHttpClientFactory httpClientFactory,
ISubtitleManager subtitleManager,
@@ -88,7 +90,8 @@ namespace MediaBrowser.Providers.Manager
IFileSystem fileSystem,
IServerApplicationPaths appPaths,
ILibraryManager libraryManager,
- IBaseItemManager baseItemManager)
+ IBaseItemManager baseItemManager,
+ ILyricManager lyricManager)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
@@ -99,6 +102,7 @@ namespace MediaBrowser.Providers.Manager
_libraryManager = libraryManager;
_subtitleManager = subtitleManager;
_baseItemManager = baseItemManager;
+ _lyricManager = lyricManager;
}
/// <inheritdoc/>
@@ -504,15 +508,22 @@ namespace MediaBrowser.Providers.Manager
AddMetadataPlugins(pluginList, dummy, libraryOptions, options);
AddImagePlugins(pluginList, imageProviders);
- var subtitleProviders = _subtitleManager.GetSupportedProviders(dummy);
-
// Subtitle fetchers
+ var subtitleProviders = _subtitleManager.GetSupportedProviders(dummy);
pluginList.AddRange(subtitleProviders.Select(i => new MetadataPlugin
{
Name = i.Name,
Type = MetadataPluginType.SubtitleFetcher
}));
+ // Lyric fetchers
+ var lyricProviders = _lyricManager.GetSupportedProviders(dummy);
+ pluginList.AddRange(lyricProviders.Select(i => new MetadataPlugin
+ {
+ Name = i.Name,
+ Type = MetadataPluginType.LyricFetcher
+ }));
+
summary.Plugins = pluginList.ToArray();
var supportedImageTypes = imageProviders.OfType<IRemoteImageProvider>()
@@ -1025,7 +1036,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 +1047,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 +1069,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);
diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
index f68faab04..e929fd081 100644
--- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
+++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
@@ -35,6 +35,7 @@ namespace MediaBrowser.Providers.MediaInfo
private readonly IItemRepository _itemRepo;
private readonly ILibraryManager _libraryManager;
private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly LyricResolver _lyricResolver;
/// <summary>
/// Initializes a new instance of the <see cref="AudioFileProber"/> class.
@@ -44,18 +45,21 @@ namespace MediaBrowser.Providers.MediaInfo
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
/// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param>
public AudioFileProber(
ILogger<AudioFileProber> logger,
IMediaSourceManager mediaSourceManager,
IMediaEncoder mediaEncoder,
IItemRepository itemRepo,
- ILibraryManager libraryManager)
+ ILibraryManager libraryManager,
+ LyricResolver lyricResolver)
{
_logger = logger;
_mediaEncoder = mediaEncoder;
_itemRepo = itemRepo;
_libraryManager = libraryManager;
_mediaSourceManager = mediaSourceManager;
+ _lyricResolver = lyricResolver;
}
[GeneratedRegex(@"I:\s+(.*?)\s+LUFS")]
@@ -207,7 +211,11 @@ namespace MediaBrowser.Providers.MediaInfo
/// <param name="mediaInfo">The <see cref="Model.MediaInfo.MediaInfo"/>.</param>
/// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
- protected void Fetch(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, MetadataRefreshOptions options, CancellationToken cancellationToken)
+ protected void Fetch(
+ Audio audio,
+ Model.MediaInfo.MediaInfo mediaInfo,
+ MetadataRefreshOptions options,
+ CancellationToken cancellationToken)
{
audio.Container = mediaInfo.Container;
audio.TotalBitrate = mediaInfo.Bitrate;
@@ -220,7 +228,12 @@ namespace MediaBrowser.Providers.MediaInfo
FetchDataFromTags(audio, options);
}
- _itemRepo.SaveMediaStreams(audio.Id, mediaInfo.MediaStreams, cancellationToken);
+ var mediaStreams = new List<MediaStream>(mediaInfo.MediaStreams);
+ AddExternalLyrics(audio, mediaStreams, options);
+
+ audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric);
+
+ _itemRepo.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken);
}
/// <summary>
@@ -369,5 +382,17 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
}
+
+ private void AddExternalLyrics(
+ Audio audio,
+ List<MediaStream> currentStreams,
+ MetadataRefreshOptions options)
+ {
+ var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
+ var externalLyricFiles = _lyricResolver.GetExternalStreams(audio, startIndex, options.DirectoryService, false);
+
+ audio.LyricFiles = externalLyricFiles.Select(i => i.Path).Distinct().ToArray();
+ currentStreams.AddRange(externalLyricFiles);
+ }
}
}
diff --git a/MediaBrowser.Providers/MediaInfo/LyricResolver.cs b/MediaBrowser.Providers/MediaInfo/LyricResolver.cs
new file mode 100644
index 000000000..52af5ea08
--- /dev/null
+++ b/MediaBrowser.Providers/MediaInfo/LyricResolver.cs
@@ -0,0 +1,39 @@
+using Emby.Naming.Common;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.MediaInfo;
+
+/// <summary>
+/// Resolves external lyric files for <see cref="Audio"/>.
+/// </summary>
+public class LyricResolver : MediaInfoResolver
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LyricResolver"/> class for external subtitle file processing.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="mediaEncoder">The media encoder.</param>
+ /// <param name="fileSystem">The file system.</param>
+ /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param>
+ public LyricResolver(
+ ILogger<LyricResolver> logger,
+ ILocalizationManager localizationManager,
+ IMediaEncoder mediaEncoder,
+ IFileSystem fileSystem,
+ NamingOptions namingOptions)
+ : base(
+ logger,
+ localizationManager,
+ mediaEncoder,
+ fileSystem,
+ namingOptions,
+ DlnaProfileType.Lyric)
+ {
+ }
+}
diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
index f846aa5de..fbec4e963 100644
--- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
+++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Emby.Naming.Common;
using Emby.Naming.ExternalFiles;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dlna;
@@ -148,7 +149,49 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
- return mediaStreams.AsReadOnly();
+ return mediaStreams;
+ }
+
+ /// <summary>
+ /// Retrieves the external streams for the provided audio.
+ /// </summary>
+ /// <param name="audio">The <see cref="Audio"/> object to search external streams for.</param>
+ /// <param name="startIndex">The stream index to start adding external streams at.</param>
+ /// <param name="directoryService">The directory service to search for files.</param>
+ /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
+ /// <returns>The external streams located.</returns>
+ public IReadOnlyList<MediaStream> GetExternalStreams(
+ Audio audio,
+ int startIndex,
+ IDirectoryService directoryService,
+ bool clearCache)
+ {
+ if (!audio.IsFileProtocol)
+ {
+ return Array.Empty<MediaStream>();
+ }
+
+ var pathInfos = GetExternalFiles(audio, directoryService, clearCache);
+
+ if (pathInfos.Count == 0)
+ {
+ return Array.Empty<MediaStream>();
+ }
+
+ var mediaStreams = new MediaStream[pathInfos.Count];
+
+ for (var i = 0; i < pathInfos.Count; i++)
+ {
+ mediaStreams[i] = new MediaStream
+ {
+ Type = MediaStreamType.Lyric,
+ Path = pathInfos[i].Path,
+ Language = pathInfos[i].Language,
+ Index = startIndex++
+ };
+ }
+
+ return mediaStreams;
}
/// <summary>
@@ -210,6 +253,58 @@ namespace MediaBrowser.Providers.MediaInfo
}
/// <summary>
+ /// Returns the external file infos for the given audio.
+ /// </summary>
+ /// <param name="audio">The <see cref="Audio"/> object to search external files for.</param>
+ /// <param name="directoryService">The directory service to search for files.</param>
+ /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
+ /// <returns>The external file paths located.</returns>
+ public IReadOnlyList<ExternalPathParserResult> GetExternalFiles(
+ Audio audio,
+ IDirectoryService directoryService,
+ bool clearCache)
+ {
+ if (!audio.IsFileProtocol)
+ {
+ return Array.Empty<ExternalPathParserResult>();
+ }
+
+ string folder = audio.ContainingFolderPath;
+ var files = directoryService.GetFilePaths(folder, clearCache, true).ToList();
+ files.Remove(audio.Path);
+ var internalMetadataPath = audio.GetInternalMetadataPath();
+ if (_fileSystem.DirectoryExists(internalMetadataPath))
+ {
+ files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true));
+ }
+
+ if (files.Count == 0)
+ {
+ return Array.Empty<ExternalPathParserResult>();
+ }
+
+ var externalPathInfos = new List<ExternalPathParserResult>();
+ ReadOnlySpan<char> prefix = audio.FileNameWithoutExtension;
+ foreach (var file in files)
+ {
+ var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
+ if (fileNameWithoutExtension.Length >= prefix.Length
+ && prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
+ && (fileNameWithoutExtension.Length == prefix.Length || _namingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[prefix.Length])))
+ {
+ var externalPathInfo = _externalPathParser.ParseFile(file, fileNameWithoutExtension[prefix.Length..].ToString());
+
+ if (externalPathInfo is not null)
+ {
+ externalPathInfos.Add(externalPathInfo);
+ }
+ }
+ }
+
+ return externalPathInfos;
+ }
+
+ /// <summary>
/// Returns the media info of the given file.
/// </summary>
/// <param name="path">The path to the file.</param>
diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
index 114a92975..8bb874f0d 100644
--- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
@@ -43,6 +43,7 @@ namespace MediaBrowser.Providers.MediaInfo
private readonly ILogger<ProbeProvider> _logger;
private readonly AudioResolver _audioResolver;
private readonly SubtitleResolver _subtitleResolver;
+ private readonly LyricResolver _lyricResolver;
private readonly FFProbeVideoInfo _videoProber;
private readonly AudioFileProber _audioProber;
private readonly Task<ItemUpdateType> _cachedTask = Task.FromResult(ItemUpdateType.None);
@@ -79,9 +80,10 @@ namespace MediaBrowser.Providers.MediaInfo
NamingOptions namingOptions)
{
_logger = loggerFactory.CreateLogger<ProbeProvider>();
- _audioProber = new AudioFileProber(loggerFactory.CreateLogger<AudioFileProber>(), mediaSourceManager, mediaEncoder, itemRepo, libraryManager);
_audioResolver = new AudioResolver(loggerFactory.CreateLogger<AudioResolver>(), localization, mediaEncoder, fileSystem, namingOptions);
_subtitleResolver = new SubtitleResolver(loggerFactory.CreateLogger<SubtitleResolver>(), localization, mediaEncoder, fileSystem, namingOptions);
+ _lyricResolver = new LyricResolver(loggerFactory.CreateLogger<LyricResolver>(), localization, mediaEncoder, fileSystem, namingOptions);
+
_videoProber = new FFProbeVideoInfo(
loggerFactory.CreateLogger<FFProbeVideoInfo>(),
mediaSourceManager,
@@ -96,6 +98,14 @@ namespace MediaBrowser.Providers.MediaInfo
libraryManager,
_audioResolver,
_subtitleResolver);
+
+ _audioProber = new AudioFileProber(
+ loggerFactory.CreateLogger<AudioFileProber>(),
+ mediaSourceManager,
+ mediaEncoder,
+ itemRepo,
+ libraryManager,
+ _lyricResolver);
}
/// <inheritdoc />
@@ -123,23 +133,37 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
- if (item.SupportsLocalMetadata && video is not null && !video.IsPlaceHolder
- && !video.SubtitleFiles.SequenceEqual(
- _subtitleResolver.GetExternalFiles(video, directoryService, false)
- .Select(info => info.Path).ToList(),
- StringComparer.Ordinal))
+ if (video is not null
+ && item.SupportsLocalMetadata
+ && !video.IsPlaceHolder)
{
- _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path);
- return true;
+ if (!video.SubtitleFiles.SequenceEqual(
+ _subtitleResolver.GetExternalFiles(video, directoryService, false)
+ .Select(info => info.Path).ToList(),
+ StringComparer.Ordinal))
+ {
+ _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path);
+ return true;
+ }
+
+ if (!video.AudioFiles.SequenceEqual(
+ _audioResolver.GetExternalFiles(video, directoryService, false)
+ .Select(info => info.Path).ToList(),
+ StringComparer.Ordinal))
+ {
+ _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
+ return true;
+ }
}
- if (item.SupportsLocalMetadata && video is not null && !video.IsPlaceHolder
- && !video.AudioFiles.SequenceEqual(
- _audioResolver.GetExternalFiles(video, directoryService, false)
- .Select(info => info.Path).ToList(),
+ if (item is Audio audio
+ && item.SupportsLocalMetadata
+ && !audio.LyricFiles.SequenceEqual(
+ _lyricResolver.GetExternalFiles(audio, directoryService, false)
+ .Select(info => info.Path).ToList(),
StringComparer.Ordinal))
{
- _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
+ _logger.LogDebug("Refreshing {ItemPath} due to external lyrics change.", item.Path);
return true;
}
diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
index 87fd2a3cd..f68b3cee6 100644
--- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
+++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
@@ -74,7 +74,7 @@ namespace MediaBrowser.Providers.Subtitles
.Where(i => i.SupportedMediaTypes.Contains(contentType) && !request.DisabledSubtitleFetchers.Contains(i.Name, StringComparison.OrdinalIgnoreCase))
.OrderBy(i =>
{
- var index = request.SubtitleFetcherOrder.ToList().IndexOf(i.Name);
+ var index = request.SubtitleFetcherOrder.IndexOf(i.Name);
return index == -1 ? int.MaxValue : index;
})
.ToArray();
diff --git a/MediaBrowser.XbmcMetadata/EntryPoint.cs b/MediaBrowser.XbmcMetadata/EntryPoint.cs
deleted file mode 100644
index a6216ef30..000000000
--- a/MediaBrowser.XbmcMetadata/EntryPoint.cs
+++ /dev/null
@@ -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);
- }
- }
- }
-}
diff --git a/MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs b/MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs
new file mode 100644
index 000000000..b2882194d
--- /dev/null
+++ b/MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs
@@ -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);
+ }
+ }
+}
diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
index 70e5b66c1..97cdc6854 100644
--- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
+++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
@@ -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;
diff --git a/README.md b/README.md
index 62ef21334..ec065f260 100644
--- a/README.md
+++ b/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:** 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:** 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.
**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.
diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64
index af309b083..6bd7d312c 100644
--- a/deployment/Dockerfile.centos.amd64
+++ b/deployment/Dockerfile.centos.amd64
@@ -20,7 +20,7 @@ RUN dnf update -yq \
&& rm -rf /var/cache/dnf
# Install DotNET SDK
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/5226a5fa-8c0b-474f-b79a-8984ad7c5beb/3113ccbf789c9fd29972835f0f334b7a/dotnet-sdk-8.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/85bcc525-4e9c-471e-9c1d-96259aa1a315/930833ef34f66fe9ee2643b0ba21621a/dotnet-sdk-8.0.201-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
&& mkdir -p dotnet-sdk \
&& tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
&& ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64
index 75a6d1e64..f1dc492de 100644
--- a/deployment/Dockerfile.fedora.amd64
+++ b/deployment/Dockerfile.fedora.amd64
@@ -20,7 +20,7 @@ RUN dnf update -yq \
&& rm -rf /var/cache/dnf
# Install DotNET SDK
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/5226a5fa-8c0b-474f-b79a-8984ad7c5beb/3113ccbf789c9fd29972835f0f334b7a/dotnet-sdk-8.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/85bcc525-4e9c-471e-9c1d-96259aa1a315/930833ef34f66fe9ee2643b0ba21621a/dotnet-sdk-8.0.201-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
&& mkdir -p dotnet-sdk \
&& tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
&& ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
diff --git a/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs b/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs
deleted file mode 100644
index 581fa000d..000000000
--- a/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs
+++ /dev/null
@@ -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());
-}
diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index 5721e2882..4ae5a9a48 100644
--- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -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);
diff --git a/src/Jellyfin.Drawing.Skia/SkiaException.cs b/src/Jellyfin.Drawing.Skia/SkiaException.cs
deleted file mode 100644
index d0e69d42c..000000000
--- a/src/Jellyfin.Drawing.Skia/SkiaException.cs
+++ /dev/null
@@ -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)
- {
- }
-}
diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs
index 65a8f4e83..213328a39 100644
--- a/src/Jellyfin.Drawing/ImageProcessor.cs
+++ b/src/Jellyfin.Drawing/ImageProcessor.cs
@@ -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))
{
diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj
index 23c4c0a9a..4a02f90f9 100644
--- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj
+++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj
@@ -21,4 +21,8 @@
<Compile Include="..\..\SharedVersion.cs" />
</ItemGroup>
+ <ItemGroup>
+ <PackageReference Include="AsyncKeyedLock" />
+ </ItemGroup>
+
</Project>
diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs
index fd8f7e59a..9d8afc23c 100644
--- a/src/Jellyfin.Extensions/StringExtensions.cs
+++ b/src/Jellyfin.Extensions/StringExtensions.cs
@@ -61,6 +61,11 @@ namespace Jellyfin.Extensions
/// <returns>The part left of the <paramref name="needle" />.</returns>
public static ReadOnlySpan<char> LeftPart(this ReadOnlySpan<char> haystack, char needle)
{
+ if (haystack.IsEmpty)
+ {
+ return ReadOnlySpan<char>.Empty;
+ }
+
var pos = haystack.IndexOf(needle);
return pos == -1 ? haystack : haystack[..pos];
}
@@ -73,6 +78,11 @@ namespace Jellyfin.Extensions
/// <returns>The part right of the <paramref name="needle" />.</returns>
public static ReadOnlySpan<char> RightPart(this ReadOnlySpan<char> haystack, char needle)
{
+ if (haystack.IsEmpty)
+ {
+ return ReadOnlySpan<char>.Empty;
+ }
+
var pos = haystack.LastIndexOf(needle);
if (pos == -1)
{
diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
index bc968f8ee..1948a9ab9 100644
--- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
+++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
@@ -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)
diff --git a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs
index 556e052d4..79c5873d5 100644
--- a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs
+++ b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs
@@ -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);
diff --git a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs
index 67d0e5295..f7888496f 100644
--- a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs
+++ b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs
@@ -1,4 +1,5 @@
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.LiveTv;
namespace Jellyfin.LiveTv.Configuration;
@@ -15,4 +16,12 @@ public static class LiveTvConfigurationExtensions
/// <returns>The <see cref="LiveTvOptions"/>.</returns>
public static LiveTvOptions GetLiveTvConfiguration(this IConfigurationManager configurationManager)
=> configurationManager.GetConfiguration<LiveTvOptions>("livetv");
+
+ /// <summary>
+ /// Gets the <see cref="XbmcMetadataOptions"/>.
+ /// </summary>
+ /// <param name="configurationManager">The <see cref="IConfigurationManager"/>.</param>
+ /// <returns>The <see cref="XbmcMetadataOptions"/>.</returns>
+ public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager)
+ => configurationManager.GetConfiguration<XbmcMetadataOptions>("xbmcmetadata");
}
diff --git a/src/Jellyfin.LiveTv/DefaultLiveTvService.cs b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs
new file mode 100644
index 000000000..318cc7acd
--- /dev/null
+++ b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs
@@ -0,0 +1,998 @@
+#nullable disable
+
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Data.Events;
+using Jellyfin.Extensions;
+using Jellyfin.LiveTv.Configuration;
+using Jellyfin.LiveTv.Timers;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv
+{
+ public sealed class DefaultLiveTvService : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds
+ {
+ public const string ServiceName = "Emby";
+
+ private readonly ILogger<DefaultLiveTvService> _logger;
+ private readonly IServerConfigurationManager _config;
+ private readonly ITunerHostManager _tunerHostManager;
+ private readonly IListingsManager _listingsManager;
+ private readonly IRecordingsManager _recordingsManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly LiveTvDtoService _tvDtoService;
+ private readonly TimerManager _timerManager;
+ private readonly SeriesTimerManager _seriesTimerManager;
+
+ public DefaultLiveTvService(
+ ILogger<DefaultLiveTvService> logger,
+ IServerConfigurationManager config,
+ ITunerHostManager tunerHostManager,
+ IListingsManager listingsManager,
+ IRecordingsManager recordingsManager,
+ ILibraryManager libraryManager,
+ LiveTvDtoService tvDtoService,
+ TimerManager timerManager,
+ SeriesTimerManager seriesTimerManager)
+ {
+ _logger = logger;
+ _config = config;
+ _libraryManager = libraryManager;
+ _tunerHostManager = tunerHostManager;
+ _listingsManager = listingsManager;
+ _recordingsManager = recordingsManager;
+ _tvDtoService = tvDtoService;
+ _timerManager = timerManager;
+ _seriesTimerManager = seriesTimerManager;
+
+ _timerManager.TimerFired += OnTimerManagerTimerFired;
+ }
+
+ public event EventHandler<GenericEventArgs<TimerInfo>> TimerCreated;
+
+ public event EventHandler<GenericEventArgs<string>> TimerCancelled;
+
+ /// <inheritdoc />
+ public string Name => ServiceName;
+
+ /// <inheritdoc />
+ public string HomePageUrl => "https://github.com/jellyfin/jellyfin";
+
+ public async Task RefreshSeriesTimers(CancellationToken cancellationToken)
+ {
+ var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false);
+
+ foreach (var timer in seriesTimers)
+ {
+ UpdateTimersForSeriesTimer(timer, false, true);
+ }
+ }
+
+ public async Task RefreshTimers(CancellationToken cancellationToken)
+ {
+ var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false);
+
+ var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
+
+ foreach (var timer in timers)
+ {
+ if (DateTime.UtcNow > timer.EndDate && _recordingsManager.GetActiveRecordingPath(timer.Id) is null)
+ {
+ _timerManager.Delete(timer);
+ continue;
+ }
+
+ if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId))
+ {
+ continue;
+ }
+
+ var program = GetProgramInfoFromCache(timer);
+ if (program is null)
+ {
+ _timerManager.Delete(timer);
+ continue;
+ }
+
+ CopyProgramInfoToTimerInfo(program, timer, tempChannelCache);
+ _timerManager.Update(timer);
+ }
+ }
+
+ private async Task<IEnumerable<ChannelInfo>> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken)
+ {
+ var channels = new List<ChannelInfo>();
+
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
+ {
+ try
+ {
+ var tunerChannels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false);
+
+ channels.AddRange(tunerChannels);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting channels");
+ }
+ }
+
+ await _listingsManager.AddProviderMetadata(channels, enableCache, cancellationToken).ConfigureAwait(false);
+
+ return channels;
+ }
+
+ public Task<IEnumerable<ChannelInfo>> GetChannelsAsync(CancellationToken cancellationToken)
+ {
+ return GetChannelsAsync(false, cancellationToken);
+ }
+
+ public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken)
+ {
+ var timers = _timerManager
+ .GetAll()
+ .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ foreach (var timer in timers)
+ {
+ CancelTimerInternal(timer.Id, true, true);
+ }
+
+ var remove = _seriesTimerManager.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase));
+ if (remove is not null)
+ {
+ _seriesTimerManager.Delete(remove);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation)
+ {
+ var timer = _timerManager.GetTimer(timerId);
+ if (timer is not null)
+ {
+ var statusChanging = timer.Status != RecordingStatus.Cancelled;
+ timer.Status = RecordingStatus.Cancelled;
+
+ if (isManualCancellation)
+ {
+ timer.IsManual = true;
+ }
+
+ if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled)
+ {
+ _timerManager.Delete(timer);
+ }
+ else
+ {
+ _timerManager.AddOrUpdate(timer, false);
+ }
+
+ if (statusChanging && TimerCancelled is not null)
+ {
+ TimerCancelled(this, new GenericEventArgs<string>(timerId));
+ }
+ }
+
+ _recordingsManager.CancelRecording(timerId, timer);
+ }
+
+ public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken)
+ {
+ CancelTimerInternal(timerId, false, true);
+ return Task.CompletedTask;
+ }
+
+ public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task<string> CreateTimer(TimerInfo info, CancellationToken cancellationToken)
+ {
+ var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ?
+ null :
+ _timerManager.GetTimerByProgramId(info.ProgramId);
+
+ if (existingTimer is not null)
+ {
+ if (existingTimer.Status == RecordingStatus.Cancelled
+ || existingTimer.Status == RecordingStatus.Completed)
+ {
+ existingTimer.Status = RecordingStatus.New;
+ existingTimer.IsManual = true;
+ _timerManager.Update(existingTimer);
+ return Task.FromResult(existingTimer.Id);
+ }
+
+ throw new ArgumentException("A scheduled recording already exists for this program.");
+ }
+
+ info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+
+ LiveTvProgram programInfo = null;
+
+ if (!string.IsNullOrWhiteSpace(info.ProgramId))
+ {
+ programInfo = GetProgramInfoFromCache(info);
+ }
+
+ if (programInfo is null)
+ {
+ _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId);
+ programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate);
+ }
+
+ if (programInfo is not null)
+ {
+ CopyProgramInfoToTimerInfo(programInfo, info);
+ }
+
+ info.IsManual = true;
+ _timerManager.Add(info);
+
+ TimerCreated?.Invoke(this, new GenericEventArgs<TimerInfo>(info));
+
+ return Task.FromResult(info.Id);
+ }
+
+ public async Task<string> CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken)
+ {
+ info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+
+ // populate info.seriesID
+ var program = GetProgramInfoFromCache(info.ProgramId);
+
+ if (program is not null)
+ {
+ info.SeriesId = program.ExternalSeriesId;
+ }
+ else
+ {
+ throw new InvalidOperationException("SeriesId for program not found");
+ }
+
+ // If any timers have already been manually created, make sure they don't get cancelled
+ var existingTimers = (await GetTimersAsync(CancellationToken.None).ConfigureAwait(false))
+ .Where(i =>
+ {
+ if (string.Equals(i.ProgramId, info.ProgramId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.ProgramId))
+ {
+ return true;
+ }
+
+ if (string.Equals(i.SeriesId, info.SeriesId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.SeriesId))
+ {
+ return true;
+ }
+
+ return false;
+ })
+ .ToList();
+
+ _seriesTimerManager.Add(info);
+
+ foreach (var timer in existingTimers)
+ {
+ timer.SeriesTimerId = info.Id;
+ timer.IsManual = true;
+
+ _timerManager.AddOrUpdate(timer, false);
+ }
+
+ UpdateTimersForSeriesTimer(info, true, false);
+
+ return info.Id;
+ }
+
+ public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken)
+ {
+ var instance = _seriesTimerManager.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (instance is not null)
+ {
+ instance.ChannelId = info.ChannelId;
+ instance.Days = info.Days;
+ instance.EndDate = info.EndDate;
+ instance.IsPostPaddingRequired = info.IsPostPaddingRequired;
+ instance.IsPrePaddingRequired = info.IsPrePaddingRequired;
+ instance.PostPaddingSeconds = info.PostPaddingSeconds;
+ instance.PrePaddingSeconds = info.PrePaddingSeconds;
+ instance.Priority = info.Priority;
+ instance.RecordAnyChannel = info.RecordAnyChannel;
+ instance.RecordAnyTime = info.RecordAnyTime;
+ instance.RecordNewOnly = info.RecordNewOnly;
+ instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary;
+ instance.KeepUpTo = info.KeepUpTo;
+ instance.KeepUntil = info.KeepUntil;
+ instance.StartDate = info.StartDate;
+
+ _seriesTimerManager.Update(instance);
+
+ UpdateTimersForSeriesTimer(instance, true, true);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken)
+ {
+ var existingTimer = _timerManager.GetTimer(updatedTimer.Id);
+
+ if (existingTimer is null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ // Only update if not currently active
+ if (_recordingsManager.GetActiveRecordingPath(updatedTimer.Id) is null)
+ {
+ existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds;
+ existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds;
+ existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired;
+ existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired;
+
+ _timerManager.Update(existingTimer);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer)
+ {
+ // Update the program info but retain the status
+ existingTimer.ChannelId = updatedTimer.ChannelId;
+ existingTimer.CommunityRating = updatedTimer.CommunityRating;
+ existingTimer.EndDate = updatedTimer.EndDate;
+ existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber;
+ existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle;
+ existingTimer.Genres = updatedTimer.Genres;
+ existingTimer.IsMovie = updatedTimer.IsMovie;
+ existingTimer.IsSeries = updatedTimer.IsSeries;
+ existingTimer.Tags = updatedTimer.Tags;
+ existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries;
+ existingTimer.IsRepeat = updatedTimer.IsRepeat;
+ existingTimer.Name = updatedTimer.Name;
+ existingTimer.OfficialRating = updatedTimer.OfficialRating;
+ existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate;
+ existingTimer.Overview = updatedTimer.Overview;
+ existingTimer.ProductionYear = updatedTimer.ProductionYear;
+ existingTimer.ProgramId = updatedTimer.ProgramId;
+ existingTimer.SeasonNumber = updatedTimer.SeasonNumber;
+ existingTimer.StartDate = updatedTimer.StartDate;
+ existingTimer.ShowId = updatedTimer.ShowId;
+ existingTimer.ProviderIds = updatedTimer.ProviderIds;
+ existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds;
+ }
+
+ public Task<IEnumerable<TimerInfo>> GetTimersAsync(CancellationToken cancellationToken)
+ {
+ var excludeStatues = new List<RecordingStatus>
+ {
+ RecordingStatus.Completed
+ };
+
+ var timers = _timerManager.GetAll()
+ .Where(i => !excludeStatues.Contains(i.Status));
+
+ return Task.FromResult(timers);
+ }
+
+ public Task<SeriesTimerInfo> GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null)
+ {
+ var config = _config.GetLiveTvConfiguration();
+
+ var defaults = new SeriesTimerInfo()
+ {
+ PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0),
+ PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0),
+ RecordAnyChannel = false,
+ RecordAnyTime = true,
+ RecordNewOnly = true,
+
+ Days = new List<DayOfWeek>
+ {
+ DayOfWeek.Sunday,
+ DayOfWeek.Monday,
+ DayOfWeek.Tuesday,
+ DayOfWeek.Wednesday,
+ DayOfWeek.Thursday,
+ DayOfWeek.Friday,
+ DayOfWeek.Saturday
+ }
+ };
+
+ if (program is not null)
+ {
+ defaults.SeriesId = program.SeriesId;
+ defaults.ProgramId = program.Id;
+ defaults.RecordNewOnly = !program.IsRepeat;
+ defaults.Name = program.Name;
+ }
+
+ defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly;
+ defaults.KeepUntil = KeepUntil.UntilDeleted;
+
+ return Task.FromResult(defaults);
+ }
+
+ public Task<IEnumerable<SeriesTimerInfo>> GetSeriesTimersAsync(CancellationToken cancellationToken)
+ {
+ return Task.FromResult((IEnumerable<SeriesTimerInfo>)_seriesTimerManager.GetAll());
+ }
+
+ public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
+ {
+ var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false);
+ var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase));
+
+ return await _listingsManager.GetProgramsAsync(channel, startDateUtc, endDateUtc, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public Task<MediaSourceInfo> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public async Task<ILiveStream> GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Streaming Channel {Id}", channelId);
+
+ var result = string.IsNullOrEmpty(streamId) ?
+ null :
+ currentLiveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase));
+
+ if (result is not null && result.EnableStreamSharing)
+ {
+ result.ConsumerCount++;
+
+ _logger.LogInformation("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount);
+
+ return result;
+ }
+
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
+ {
+ try
+ {
+ result = await hostInstance.GetChannelStream(channelId, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
+
+ var openedMediaSource = result.MediaSource;
+
+ result.OriginalStreamId = streamId;
+
+ _logger.LogInformation("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId);
+
+ return result;
+ }
+ catch (FileNotFoundException)
+ {
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
+
+ throw new ResourceNotFoundException("Tuner not found.");
+ }
+
+ public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(channelId))
+ {
+ throw new ArgumentNullException(nameof(channelId));
+ }
+
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
+ {
+ try
+ {
+ var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false);
+
+ if (sources.Count > 0)
+ {
+ return sources;
+ }
+ }
+ catch (NotImplementedException)
+ {
+ }
+ }
+
+ throw new NotImplementedException();
+ }
+
+ public Task CloseLiveStream(string id, CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ public Task ResetTuner(string id, CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ private async void OnTimerManagerTimerFired(object sender, GenericEventArgs<TimerInfo> e)
+ {
+ var timer = e.Argument;
+
+ _logger.LogInformation("Recording timer fired for {0}.", timer.Name);
+
+ try
+ {
+ var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds);
+ if (recordingEndDate <= DateTime.UtcNow)
+ {
+ _logger.LogWarning("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id);
+ _timerManager.Delete(timer);
+ return;
+ }
+
+ var activeRecordingInfo = new ActiveRecordingInfo
+ {
+ CancellationTokenSource = new CancellationTokenSource(),
+ Timer = timer,
+ Id = timer.Id
+ };
+
+ if (_recordingsManager.GetActiveRecordingPath(timer.Id) is not null)
+ {
+ _logger.LogInformation("Skipping RecordStream because it's already in progress.");
+ return;
+ }
+
+ LiveTvProgram programInfo = null;
+ if (!string.IsNullOrWhiteSpace(timer.ProgramId))
+ {
+ programInfo = GetProgramInfoFromCache(timer);
+ }
+
+ if (programInfo is null)
+ {
+ _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId);
+ programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate);
+ }
+
+ if (programInfo is not null)
+ {
+ CopyProgramInfoToTimerInfo(programInfo, timer);
+ }
+
+ await _recordingsManager.RecordStream(activeRecordingInfo, GetLiveTvChannel(timer), recordingEndDate)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error recording stream");
+ }
+ }
+
+ private BaseItem GetLiveTvChannel(TimerInfo timer)
+ {
+ var internalChannelId = _tvDtoService.GetInternalChannelId(Name, timer.ChannelId);
+ return _libraryManager.GetItemById(internalChannelId);
+ }
+
+ private LiveTvProgram GetProgramInfoFromCache(string programId)
+ {
+ var query = new InternalItemsQuery
+ {
+ ItemIds = [_tvDtoService.GetInternalProgramId(programId)],
+ Limit = 1,
+ DtoOptions = new DtoOptions()
+ };
+
+ return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().FirstOrDefault();
+ }
+
+ private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer)
+ {
+ return GetProgramInfoFromCache(timer.ProgramId);
+ }
+
+ private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc)
+ {
+ var query = new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
+ Limit = 1,
+ DtoOptions = new DtoOptions(true)
+ {
+ EnableImages = false
+ },
+ MinStartDate = startDateUtc.AddMinutes(-3),
+ MaxStartDate = startDateUtc.AddMinutes(3),
+ OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) }
+ };
+
+ if (!string.IsNullOrWhiteSpace(channelId))
+ {
+ query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, channelId)];
+ }
+
+ return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().FirstOrDefault();
+ }
+
+ private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer)
+ {
+ if (timer.IsManual)
+ {
+ return false;
+ }
+
+ if (!seriesTimer.RecordAnyTime
+ && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks)
+ {
+ return true;
+ }
+
+ if (seriesTimer.RecordNewOnly && timer.IsRepeat)
+ {
+ return true;
+ }
+
+ if (!seriesTimer.RecordAnyChannel
+ && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer);
+ }
+
+ 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 => GetLiveTvChannel(t).IsHD).ThenBy(t => t.StartDate).Skip(1))
+ {
+ timer.Status = RecordingStatus.Cancelled;
+ _timerManager.Update(timer);
+ }
+ }
+
+ private void SearchForDuplicateShowIds(IEnumerable<TimerInfo> timers)
+ {
+ var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList();
+
+ foreach (var group in groups)
+ {
+ if (string.IsNullOrWhiteSpace(group.Key))
+ {
+ continue;
+ }
+
+ var groupTimers = group.ToList();
+
+ if (groupTimers.Count < 2)
+ {
+ continue;
+ }
+
+ // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856
+ if (group.Key.EndsWith("0000", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ HandleDuplicateShowIds(groupTimers);
+ }
+ }
+
+ private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers)
+ {
+ var allTimers = GetTimersForSeries(seriesTimer).ToList();
+
+ var enabledTimersForSeries = new List<TimerInfo>();
+ foreach (var timer in allTimers)
+ {
+ var existingTimer = _timerManager.GetTimer(timer.Id)
+ ?? (string.IsNullOrWhiteSpace(timer.ProgramId)
+ ? null
+ : _timerManager.GetTimerByProgramId(timer.ProgramId));
+
+ if (existingTimer is null)
+ {
+ if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer))
+ {
+ timer.Status = RecordingStatus.Cancelled;
+ }
+ else
+ {
+ enabledTimersForSeries.Add(timer);
+ }
+
+ _timerManager.Add(timer);
+
+ TimerCreated?.Invoke(this, new GenericEventArgs<TimerInfo>(timer));
+ }
+
+ // Only update if not currently active - test both new timer and existing in case Id's are different
+ // Id's could be different if the timer was created manually prior to series timer creation
+ else if (_recordingsManager.GetActiveRecordingPath(timer.Id) is null
+ && _recordingsManager.GetActiveRecordingPath(existingTimer.Id) is null)
+ {
+ UpdateExistingTimerWithNewMetadata(existingTimer, timer);
+
+ // Needed by ShouldCancelTimerForSeriesTimer
+ timer.IsManual = existingTimer.IsManual;
+
+ if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer))
+ {
+ existingTimer.Status = RecordingStatus.Cancelled;
+ }
+ else if (!existingTimer.IsManual)
+ {
+ existingTimer.Status = RecordingStatus.New;
+ }
+
+ if (existingTimer.Status != RecordingStatus.Cancelled)
+ {
+ enabledTimersForSeries.Add(existingTimer);
+ }
+
+ if (updateTimerSettings)
+ {
+ existingTimer.KeepUntil = seriesTimer.KeepUntil;
+ existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired;
+ existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired;
+ existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds;
+ existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds;
+ existingTimer.Priority = seriesTimer.Priority;
+ existingTimer.SeriesTimerId = seriesTimer.Id;
+ }
+
+ existingTimer.SeriesTimerId = seriesTimer.Id;
+ _timerManager.Update(existingTimer);
+ }
+ }
+
+ SearchForDuplicateShowIds(enabledTimersForSeries);
+
+ if (deleteInvalidTimers)
+ {
+ var allTimerIds = allTimers
+ .Select(i => i.Id)
+ .ToList();
+
+ var deleteStatuses = new[]
+ {
+ RecordingStatus.New
+ };
+
+ var deletes = _timerManager.GetAll()
+ .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase))
+ .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow)
+ .Where(i => deleteStatuses.Contains(i.Status))
+ .ToList();
+
+ foreach (var timer in deletes)
+ {
+ CancelTimerInternal(timer.Id, false, false);
+ }
+ }
+ }
+
+ private IEnumerable<TimerInfo> GetTimersForSeries(SeriesTimerInfo seriesTimer)
+ {
+ ArgumentNullException.ThrowIfNull(seriesTimer);
+
+ var query = new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
+ ExternalSeriesId = seriesTimer.SeriesId,
+ DtoOptions = new DtoOptions(true)
+ {
+ EnableImages = false
+ },
+ MinEndDate = DateTime.UtcNow
+ };
+
+ if (string.IsNullOrEmpty(seriesTimer.SeriesId))
+ {
+ query.Name = seriesTimer.Name;
+ }
+
+ if (!seriesTimer.RecordAnyChannel)
+ {
+ query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, seriesTimer.ChannelId)];
+ }
+
+ var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
+
+ return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().Select(i => CreateTimer(i, seriesTimer, tempChannelCache));
+ }
+
+ private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary<Guid, LiveTvChannel> tempChannelCache)
+ {
+ string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId;
+
+ if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.IsEmpty())
+ {
+ if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel))
+ {
+ channel = _libraryManager.GetItemList(
+ new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel },
+ ItemIds = new[] { parent.ChannelId },
+ DtoOptions = new DtoOptions()
+ }).FirstOrDefault() as LiveTvChannel;
+
+ if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId))
+ {
+ tempChannelCache[parent.ChannelId] = channel;
+ }
+ }
+
+ if (channel is not null || tempChannelCache.TryGetValue(parent.ChannelId, out channel))
+ {
+ channelId = channel.ExternalId;
+ }
+ }
+
+ var timer = new TimerInfo
+ {
+ ChannelId = channelId,
+ Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture),
+ StartDate = parent.StartDate,
+ EndDate = parent.EndDate.Value,
+ ProgramId = parent.ExternalId,
+ PrePaddingSeconds = seriesTimer.PrePaddingSeconds,
+ PostPaddingSeconds = seriesTimer.PostPaddingSeconds,
+ IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired,
+ IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired,
+ KeepUntil = seriesTimer.KeepUntil,
+ Priority = seriesTimer.Priority,
+ Name = parent.Name,
+ Overview = parent.Overview,
+ SeriesId = parent.ExternalSeriesId,
+ SeriesTimerId = seriesTimer.Id,
+ ShowId = parent.ShowId
+ };
+
+ CopyProgramInfoToTimerInfo(parent, timer, tempChannelCache);
+
+ return timer;
+ }
+
+ private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo)
+ {
+ var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
+ CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache);
+ }
+
+ private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary<Guid, LiveTvChannel> tempChannelCache)
+ {
+ string channelId = null;
+
+ if (!programInfo.ChannelId.IsEmpty())
+ {
+ if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel))
+ {
+ channel = _libraryManager.GetItemList(
+ new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel },
+ ItemIds = new[] { programInfo.ChannelId },
+ DtoOptions = new DtoOptions()
+ }).FirstOrDefault() as LiveTvChannel;
+
+ if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId))
+ {
+ tempChannelCache[programInfo.ChannelId] = channel;
+ }
+ }
+
+ if (channel is not null || tempChannelCache.TryGetValue(programInfo.ChannelId, out channel))
+ {
+ channelId = channel.ExternalId;
+ }
+ }
+
+ timerInfo.Name = programInfo.Name;
+ timerInfo.StartDate = programInfo.StartDate;
+ timerInfo.EndDate = programInfo.EndDate.Value;
+
+ if (!string.IsNullOrWhiteSpace(channelId))
+ {
+ timerInfo.ChannelId = channelId;
+ }
+
+ timerInfo.SeasonNumber = programInfo.ParentIndexNumber;
+ timerInfo.EpisodeNumber = programInfo.IndexNumber;
+ timerInfo.IsMovie = programInfo.IsMovie;
+ timerInfo.ProductionYear = programInfo.ProductionYear;
+ timerInfo.EpisodeTitle = programInfo.EpisodeTitle;
+ timerInfo.OriginalAirDate = programInfo.PremiereDate;
+ timerInfo.IsProgramSeries = programInfo.IsSeries;
+
+ timerInfo.IsSeries = programInfo.IsSeries;
+
+ timerInfo.CommunityRating = programInfo.CommunityRating;
+ timerInfo.Overview = programInfo.Overview;
+ timerInfo.OfficialRating = programInfo.OfficialRating;
+ timerInfo.IsRepeat = programInfo.IsRepeat;
+ timerInfo.SeriesId = programInfo.ExternalSeriesId;
+ timerInfo.ProviderIds = programInfo.ProviderIds;
+ timerInfo.Tags = programInfo.Tags;
+
+ var seriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var providerId in timerInfo.ProviderIds)
+ {
+ const string Search = "Series";
+ if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase))
+ {
+ seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value;
+ }
+ }
+
+ timerInfo.SeriesProviderIds = seriesProviderIds;
+ }
+
+ private bool IsProgramAlreadyInLibrary(TimerInfo program)
+ {
+ if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle))
+ {
+ var seriesIds = _libraryManager.GetItemIds(
+ new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.Series },
+ Name = program.Name
+ }).ToArray();
+
+ if (seriesIds.Length == 0)
+ {
+ return false;
+ }
+
+ if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue)
+ {
+ var result = _libraryManager.GetItemIds(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.Episode },
+ ParentIndexNumber = program.SeasonNumber.Value,
+ IndexNumber = program.EpisodeNumber.Value,
+ AncestorIds = seriesIds,
+ IsVirtualItem = false,
+ Limit = 1
+ });
+
+ if (result.Count > 0)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
deleted file mode 100644
index e7e927b2d..000000000
--- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
+++ /dev/null
@@ -1,2537 +0,0 @@
-#nullable disable
-
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Net.Http;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using System.Xml;
-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;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.LiveTv;
-using MediaBrowser.Controller.MediaEncoding;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Configuration;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.IO;
-using MediaBrowser.Model.LiveTv;
-using MediaBrowser.Model.MediaInfo;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-
-namespace Jellyfin.LiveTv.EmbyTV
-{
- public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable
- {
- public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss";
-
- private readonly ILogger<EmbyTV> _logger;
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly IServerConfigurationManager _config;
-
- private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider;
- private readonly TimerManager _timerProvider;
-
- private readonly LiveTvManager _liveTvManager;
- private readonly ITunerHostManager _tunerHostManager;
- private readonly IFileSystem _fileSystem;
-
- private readonly ILibraryMonitor _libraryMonitor;
- private readonly ILibraryManager _libraryManager;
- private readonly IProviderManager _providerManager;
- private readonly IMediaEncoder _mediaEncoder;
- private readonly IMediaSourceManager _mediaSourceManager;
- private readonly IStreamHelper _streamHelper;
-
- private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings =
- new ConcurrentDictionary<string, ActiveRecordingInfo>(StringComparer.OrdinalIgnoreCase);
-
- private readonly ConcurrentDictionary<string, EpgChannelData> _epgChannels =
- new ConcurrentDictionary<string, EpgChannelData>(StringComparer.OrdinalIgnoreCase);
-
- private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1);
-
- private bool _disposed;
-
- public EmbyTV(
- IStreamHelper streamHelper,
- IMediaSourceManager mediaSourceManager,
- ILogger<EmbyTV> logger,
- IHttpClientFactory httpClientFactory,
- IServerConfigurationManager config,
- ILiveTvManager liveTvManager,
- ITunerHostManager tunerHostManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager,
- ILibraryMonitor libraryMonitor,
- IProviderManager providerManager,
- IMediaEncoder mediaEncoder)
- {
- Current = this;
-
- _logger = logger;
- _httpClientFactory = httpClientFactory;
- _config = config;
- _fileSystem = fileSystem;
- _libraryManager = libraryManager;
- _libraryMonitor = libraryMonitor;
- _providerManager = providerManager;
- _mediaEncoder = mediaEncoder;
- _liveTvManager = (LiveTvManager)liveTvManager;
- _tunerHostManager = tunerHostManager;
- _mediaSourceManager = mediaSourceManager;
- _streamHelper = streamHelper;
-
- _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json"));
- _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json"));
- _timerProvider.TimerFired += OnTimerProviderTimerFired;
-
- _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
- }
-
- public event EventHandler<GenericEventArgs<TimerInfo>> TimerCreated;
-
- public event EventHandler<GenericEventArgs<string>> TimerCancelled;
-
- public static EmbyTV Current { get; private set; }
-
- /// <inheritdoc />
- public string Name => "Emby";
-
- public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv");
-
- /// <inheritdoc />
- public string HomePageUrl => "https://github.com/jellyfin/jellyfin";
-
- private string DefaultRecordingPath => Path.Combine(DataPath, "recordings");
-
- private string RecordingPath
- {
- get
- {
- var path = _config.GetLiveTvConfiguration().RecordingPath;
-
- return string.IsNullOrWhiteSpace(path)
- ? DefaultRecordingPath
- : path;
- }
- }
-
- private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
- {
- if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase))
- {
- await CreateRecordingFolders().ConfigureAwait(false);
- }
- }
-
- public Task Start()
- {
- _timerProvider.RestartTimers();
-
- return CreateRecordingFolders();
- }
-
- internal async Task CreateRecordingFolders()
- {
- try
- {
- var recordingFolders = GetRecordingFolders().ToArray();
- var virtualFolders = _libraryManager.GetVirtualFolders();
-
- var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList();
-
- var pathsAdded = new List<string>();
-
- foreach (var recordingFolder in recordingFolders)
- {
- var pathsToCreate = recordingFolder.Locations
- .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i)))
- .ToList();
-
- if (pathsToCreate.Count == 0)
- {
- continue;
- }
-
- var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray();
-
- var libraryOptions = new LibraryOptions
- {
- PathInfos = mediaPathInfos
- };
- try
- {
- await _libraryManager.AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error creating virtual folder");
- }
-
- pathsAdded.AddRange(pathsToCreate);
- }
-
- var config = _config.GetLiveTvConfiguration();
-
- var pathsToRemove = config.MediaLocationsCreated
- .Except(recordingFolders.SelectMany(i => i.Locations))
- .ToList();
-
- if (pathsAdded.Count > 0 || pathsToRemove.Count > 0)
- {
- pathsAdded.InsertRange(0, config.MediaLocationsCreated);
- config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
- _config.SaveConfiguration("livetv", config);
- }
-
- foreach (var path in pathsToRemove)
- {
- await RemovePathFromLibraryAsync(path).ConfigureAwait(false);
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error creating recording folders");
- }
- }
-
- private async Task RemovePathFromLibraryAsync(string path)
- {
- _logger.LogDebug("Removing path from library: {0}", path);
-
- var requiresRefresh = false;
- var virtualFolders = _libraryManager.GetVirtualFolders();
-
- foreach (var virtualFolder in virtualFolders)
- {
- if (!virtualFolder.Locations.Contains(path, StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- if (virtualFolder.Locations.Length == 1)
- {
- // remove entire virtual folder
- try
- {
- await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error removing virtual folder");
- }
- }
- else
- {
- try
- {
- _libraryManager.RemoveMediaPath(virtualFolder.Name, path);
- requiresRefresh = true;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error removing media path");
- }
- }
- }
-
- if (requiresRefresh)
- {
- await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
- }
- }
-
- public async Task RefreshSeriesTimers(CancellationToken cancellationToken)
- {
- var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false);
-
- foreach (var timer in seriesTimers)
- {
- UpdateTimersForSeriesTimer(timer, false, true);
- }
- }
-
- public async Task RefreshTimers(CancellationToken cancellationToken)
- {
- var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false);
-
- var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
-
- foreach (var timer in timers)
- {
- if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id))
- {
- OnTimerOutOfDate(timer);
- continue;
- }
-
- if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId))
- {
- continue;
- }
-
- var program = GetProgramInfoFromCache(timer);
- if (program is null)
- {
- OnTimerOutOfDate(timer);
- continue;
- }
-
- CopyProgramInfoToTimerInfo(program, timer, tempChannelCache);
- _timerProvider.Update(timer);
- }
- }
-
- private void OnTimerOutOfDate(TimerInfo timer)
- {
- _timerProvider.Delete(timer);
- }
-
- private async Task<IEnumerable<ChannelInfo>> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken)
- {
- var list = new List<ChannelInfo>();
-
- foreach (var hostInstance in _tunerHostManager.TunerHosts)
- {
- try
- {
- var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false);
-
- list.AddRange(channels);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error getting channels");
- }
- }
-
- foreach (var provider in GetListingProviders())
- {
- var enabledChannels = list
- .Where(i => IsListingProviderEnabledForTuner(provider.Item2, i.TunerHostId))
- .ToList();
-
- if (enabledChannels.Count > 0)
- {
- try
- {
- await AddMetadata(provider.Item1, provider.Item2, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false);
- }
- catch (NotSupportedException)
- {
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error adding metadata");
- }
- }
- }
-
- return list;
- }
-
- private async Task AddMetadata(
- IListingsProvider provider,
- ListingsProviderInfo info,
- IEnumerable<ChannelInfo> tunerChannels,
- bool enableCache,
- CancellationToken cancellationToken)
- {
- var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false);
-
- foreach (var tunerChannel in tunerChannels)
- {
- var epgChannel = GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels);
-
- if (epgChannel is not null)
- {
- if (!string.IsNullOrWhiteSpace(epgChannel.Name))
- {
- // tunerChannel.Name = epgChannel.Name;
- }
-
- if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl))
- {
- tunerChannel.ImageUrl = epgChannel.ImageUrl;
- }
- }
- }
- }
-
- private async Task<EpgChannelData> GetEpgChannels(
- IListingsProvider provider,
- ListingsProviderInfo info,
- bool enableCache,
- CancellationToken cancellationToken)
- {
- if (!enableCache || !_epgChannels.TryGetValue(info.Id, out var result))
- {
- var channels = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false);
-
- foreach (var channel in channels)
- {
- _logger.LogInformation("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name, channel.Id);
- }
-
- result = new EpgChannelData(channels);
- _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result);
- }
-
- return result;
- }
-
- private async Task<ChannelInfo> GetEpgChannelFromTunerChannel(IListingsProvider provider, ListingsProviderInfo info, ChannelInfo tunerChannel, CancellationToken cancellationToken)
- {
- var epgChannels = await GetEpgChannels(provider, info, true, cancellationToken).ConfigureAwait(false);
-
- return GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels);
- }
-
- private static string GetMappedChannel(string channelId, NameValuePair[] mappings)
- {
- foreach (NameValuePair mapping in mappings)
- {
- if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase))
- {
- return mapping.Value;
- }
- }
-
- return channelId;
- }
-
- internal ChannelInfo GetEpgChannelFromTunerChannel(NameValuePair[] mappings, ChannelInfo tunerChannel, List<ChannelInfo> epgChannels)
- {
- return GetEpgChannelFromTunerChannel(mappings, tunerChannel, new EpgChannelData(epgChannels));
- }
-
- private ChannelInfo GetEpgChannelFromTunerChannel(ListingsProviderInfo info, ChannelInfo tunerChannel, EpgChannelData epgChannels)
- {
- return GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels);
- }
-
- private ChannelInfo GetEpgChannelFromTunerChannel(
- NameValuePair[] mappings,
- ChannelInfo tunerChannel,
- EpgChannelData epgChannelData)
- {
- if (!string.IsNullOrWhiteSpace(tunerChannel.Id))
- {
- var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings);
-
- if (string.IsNullOrWhiteSpace(mappedTunerChannelId))
- {
- mappedTunerChannelId = tunerChannel.Id;
- }
-
- var channel = epgChannelData.GetChannelById(mappedTunerChannelId);
-
- if (channel is not null)
- {
- return channel;
- }
- }
-
- if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId))
- {
- var tunerChannelId = tunerChannel.TunerChannelId;
- if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase))
- {
- tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I');
- }
-
- var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings);
-
- if (string.IsNullOrWhiteSpace(mappedTunerChannelId))
- {
- mappedTunerChannelId = tunerChannelId;
- }
-
- var channel = epgChannelData.GetChannelById(mappedTunerChannelId);
-
- if (channel is not null)
- {
- return channel;
- }
- }
-
- if (!string.IsNullOrWhiteSpace(tunerChannel.Number))
- {
- var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings);
-
- if (string.IsNullOrWhiteSpace(tunerChannelNumber))
- {
- tunerChannelNumber = tunerChannel.Number;
- }
-
- var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber);
-
- if (channel is not null)
- {
- return channel;
- }
- }
-
- if (!string.IsNullOrWhiteSpace(tunerChannel.Name))
- {
- var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name);
-
- var channel = epgChannelData.GetChannelByName(normalizedName);
-
- if (channel is not null)
- {
- return channel;
- }
- }
-
- return null;
- }
-
- public async Task<List<ChannelInfo>> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken)
- {
- var list = new List<ChannelInfo>();
-
- foreach (var hostInstance in _tunerHostManager.TunerHosts)
- {
- try
- {
- var channels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false);
-
- list.AddRange(channels);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error getting channels");
- }
- }
-
- return list
- .Where(i => IsListingProviderEnabledForTuner(listingsProvider, i.TunerHostId))
- .ToList();
- }
-
- public Task<IEnumerable<ChannelInfo>> GetChannelsAsync(CancellationToken cancellationToken)
- {
- return GetChannelsAsync(false, cancellationToken);
- }
-
- public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken)
- {
- var timers = _timerProvider
- .GetAll()
- .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase))
- .ToList();
-
- foreach (var timer in timers)
- {
- CancelTimerInternal(timer.Id, true, true);
- }
-
- var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase));
- if (remove is not null)
- {
- _seriesTimerProvider.Delete(remove);
- }
-
- return Task.CompletedTask;
- }
-
- private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation)
- {
- var timer = _timerProvider.GetTimer(timerId);
- if (timer is not null)
- {
- var statusChanging = timer.Status != RecordingStatus.Cancelled;
- timer.Status = RecordingStatus.Cancelled;
-
- if (isManualCancellation)
- {
- timer.IsManual = true;
- }
-
- if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled)
- {
- _timerProvider.Delete(timer);
- }
- else
- {
- _timerProvider.AddOrUpdate(timer, false);
- }
-
- if (statusChanging && TimerCancelled is not null)
- {
- TimerCancelled(this, new GenericEventArgs<string>(timerId));
- }
- }
-
- if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo))
- {
- activeRecordingInfo.Timer = timer;
- activeRecordingInfo.CancellationTokenSource.Cancel();
- }
- }
-
- public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken)
- {
- CancelTimerInternal(timerId, false, true);
- return Task.CompletedTask;
- }
-
- public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken)
- {
- throw new NotImplementedException();
- }
-
- public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken)
- {
- throw new NotImplementedException();
- }
-
- public Task<string> CreateTimer(TimerInfo info, CancellationToken cancellationToken)
- {
- var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ?
- null :
- _timerProvider.GetTimerByProgramId(info.ProgramId);
-
- if (existingTimer is not null)
- {
- if (existingTimer.Status == RecordingStatus.Cancelled
- || existingTimer.Status == RecordingStatus.Completed)
- {
- existingTimer.Status = RecordingStatus.New;
- existingTimer.IsManual = true;
- _timerProvider.Update(existingTimer);
- return Task.FromResult(existingTimer.Id);
- }
-
- throw new ArgumentException("A scheduled recording already exists for this program.");
- }
-
- info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
-
- LiveTvProgram programInfo = null;
-
- if (!string.IsNullOrWhiteSpace(info.ProgramId))
- {
- programInfo = GetProgramInfoFromCache(info);
- }
-
- if (programInfo is null)
- {
- _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId);
- programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate);
- }
-
- if (programInfo is not null)
- {
- CopyProgramInfoToTimerInfo(programInfo, info);
- }
-
- info.IsManual = true;
- _timerProvider.Add(info);
-
- TimerCreated?.Invoke(this, new GenericEventArgs<TimerInfo>(info));
-
- return Task.FromResult(info.Id);
- }
-
- public async Task<string> CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken)
- {
- info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
-
- // populate info.seriesID
- var program = GetProgramInfoFromCache(info.ProgramId);
-
- if (program is not null)
- {
- info.SeriesId = program.ExternalSeriesId;
- }
- else
- {
- throw new InvalidOperationException("SeriesId for program not found");
- }
-
- // If any timers have already been manually created, make sure they don't get cancelled
- var existingTimers = (await GetTimersAsync(CancellationToken.None).ConfigureAwait(false))
- .Where(i =>
- {
- if (string.Equals(i.ProgramId, info.ProgramId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.ProgramId))
- {
- return true;
- }
-
- if (string.Equals(i.SeriesId, info.SeriesId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.SeriesId))
- {
- return true;
- }
-
- return false;
- })
- .ToList();
-
- _seriesTimerProvider.Add(info);
-
- foreach (var timer in existingTimers)
- {
- timer.SeriesTimerId = info.Id;
- timer.IsManual = true;
-
- _timerProvider.AddOrUpdate(timer, false);
- }
-
- UpdateTimersForSeriesTimer(info, true, false);
-
- return info.Id;
- }
-
- public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken)
- {
- var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
-
- if (instance is not null)
- {
- instance.ChannelId = info.ChannelId;
- instance.Days = info.Days;
- instance.EndDate = info.EndDate;
- instance.IsPostPaddingRequired = info.IsPostPaddingRequired;
- instance.IsPrePaddingRequired = info.IsPrePaddingRequired;
- instance.PostPaddingSeconds = info.PostPaddingSeconds;
- instance.PrePaddingSeconds = info.PrePaddingSeconds;
- instance.Priority = info.Priority;
- instance.RecordAnyChannel = info.RecordAnyChannel;
- instance.RecordAnyTime = info.RecordAnyTime;
- instance.RecordNewOnly = info.RecordNewOnly;
- instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary;
- instance.KeepUpTo = info.KeepUpTo;
- instance.KeepUntil = info.KeepUntil;
- instance.StartDate = info.StartDate;
-
- _seriesTimerProvider.Update(instance);
-
- UpdateTimersForSeriesTimer(instance, true, true);
- }
-
- return Task.CompletedTask;
- }
-
- public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken)
- {
- var existingTimer = _timerProvider.GetTimer(updatedTimer.Id);
-
- if (existingTimer is null)
- {
- throw new ResourceNotFoundException();
- }
-
- // Only update if not currently active
- if (!_activeRecordings.TryGetValue(updatedTimer.Id, out _))
- {
- existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds;
- existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds;
- existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired;
- existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired;
-
- _timerProvider.Update(existingTimer);
- }
-
- return Task.CompletedTask;
- }
-
- private static void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer)
- {
- // Update the program info but retain the status
- existingTimer.ChannelId = updatedTimer.ChannelId;
- existingTimer.CommunityRating = updatedTimer.CommunityRating;
- existingTimer.EndDate = updatedTimer.EndDate;
- existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber;
- existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle;
- existingTimer.Genres = updatedTimer.Genres;
- existingTimer.IsMovie = updatedTimer.IsMovie;
- existingTimer.IsSeries = updatedTimer.IsSeries;
- existingTimer.Tags = updatedTimer.Tags;
- existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries;
- existingTimer.IsRepeat = updatedTimer.IsRepeat;
- existingTimer.Name = updatedTimer.Name;
- existingTimer.OfficialRating = updatedTimer.OfficialRating;
- existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate;
- existingTimer.Overview = updatedTimer.Overview;
- existingTimer.ProductionYear = updatedTimer.ProductionYear;
- existingTimer.ProgramId = updatedTimer.ProgramId;
- existingTimer.SeasonNumber = updatedTimer.SeasonNumber;
- existingTimer.StartDate = updatedTimer.StartDate;
- existingTimer.ShowId = updatedTimer.ShowId;
- existingTimer.ProviderIds = updatedTimer.ProviderIds;
- existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds;
- }
-
- public string GetActiveRecordingPath(string id)
- {
- if (_activeRecordings.TryGetValue(id, out var info))
- {
- return info.Path;
- }
-
- return null;
- }
-
- public ActiveRecordingInfo GetActiveRecordingInfo(string path)
- {
- if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty)
- {
- return null;
- }
-
- foreach (var (_, recordingInfo) in _activeRecordings)
- {
- if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) && !recordingInfo.CancellationTokenSource.IsCancellationRequested)
- {
- var timer = recordingInfo.Timer;
- if (timer.Status != RecordingStatus.InProgress)
- {
- return null;
- }
-
- return recordingInfo;
- }
- }
-
- return null;
- }
-
- public Task<IEnumerable<TimerInfo>> GetTimersAsync(CancellationToken cancellationToken)
- {
- var excludeStatues = new List<RecordingStatus>
- {
- RecordingStatus.Completed
- };
-
- var timers = _timerProvider.GetAll()
- .Where(i => !excludeStatues.Contains(i.Status));
-
- return Task.FromResult(timers);
- }
-
- public Task<SeriesTimerInfo> GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null)
- {
- var config = _config.GetLiveTvConfiguration();
-
- var defaults = new SeriesTimerInfo()
- {
- PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0),
- PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0),
- RecordAnyChannel = false,
- RecordAnyTime = true,
- RecordNewOnly = true,
-
- Days = new List<DayOfWeek>
- {
- DayOfWeek.Sunday,
- DayOfWeek.Monday,
- DayOfWeek.Tuesday,
- DayOfWeek.Wednesday,
- DayOfWeek.Thursday,
- DayOfWeek.Friday,
- DayOfWeek.Saturday
- }
- };
-
- if (program is not null)
- {
- defaults.SeriesId = program.SeriesId;
- defaults.ProgramId = program.Id;
- defaults.RecordNewOnly = !program.IsRepeat;
- defaults.Name = program.Name;
- }
-
- defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly;
- defaults.KeepUntil = KeepUntil.UntilDeleted;
-
- return Task.FromResult(defaults);
- }
-
- public Task<IEnumerable<SeriesTimerInfo>> GetSeriesTimersAsync(CancellationToken cancellationToken)
- {
- return Task.FromResult((IEnumerable<SeriesTimerInfo>)_seriesTimerProvider.GetAll());
- }
-
- private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId)
- {
- if (info.EnableAllTuners)
- {
- return true;
- }
-
- if (string.IsNullOrWhiteSpace(tunerHostId))
- {
- throw new ArgumentNullException(nameof(tunerHostId));
- }
-
- return info.EnabledTuners.Contains(tunerHostId, StringComparison.OrdinalIgnoreCase);
- }
-
- public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
- {
- var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false);
- var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase));
-
- foreach (var provider in GetListingProviders())
- {
- if (!IsListingProviderEnabledForTuner(provider.Item2, channel.TunerHostId))
- {
- _logger.LogDebug("Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty);
- continue;
- }
-
- _logger.LogDebug("Getting programs for channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty);
-
- var epgChannel = await GetEpgChannelFromTunerChannel(provider.Item1, provider.Item2, channel, cancellationToken).ConfigureAwait(false);
-
- if (epgChannel is null)
- {
- _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty);
- continue;
- }
-
- List<ProgramInfo> programs = (await provider.Item1.GetProgramsAsync(provider.Item2, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken)
- .ConfigureAwait(false)).ToList();
-
- // Replace the value that came from the provider with a normalized value
- foreach (var program in programs)
- {
- program.ChannelId = channelId;
-
- program.Id += "_" + channelId;
- }
-
- if (programs.Count > 0)
- {
- return programs;
- }
- }
-
- return Enumerable.Empty<ProgramInfo>();
- }
-
- private List<Tuple<IListingsProvider, ListingsProviderInfo>> GetListingProviders()
- {
- return _config.GetLiveTvConfiguration().ListingProviders
- .Select(i =>
- {
- var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase));
-
- return provider is null ? null : new Tuple<IListingsProvider, ListingsProviderInfo>(provider, i);
- })
- .Where(i => i is not null)
- .ToList();
- }
-
- public Task<MediaSourceInfo> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken)
- {
- throw new NotImplementedException();
- }
-
- public async Task<ILiveStream> GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
- {
- _logger.LogInformation("Streaming Channel {Id}", channelId);
-
- var result = string.IsNullOrEmpty(streamId) ?
- null :
- currentLiveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase));
-
- if (result is not null && result.EnableStreamSharing)
- {
- result.ConsumerCount++;
-
- _logger.LogInformation("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount);
-
- return result;
- }
-
- foreach (var hostInstance in _tunerHostManager.TunerHosts)
- {
- try
- {
- result = await hostInstance.GetChannelStream(channelId, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
-
- var openedMediaSource = result.MediaSource;
-
- result.OriginalStreamId = streamId;
-
- _logger.LogInformation("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId);
-
- return result;
- }
- catch (FileNotFoundException)
- {
- }
- catch (OperationCanceledException)
- {
- }
- }
-
- throw new ResourceNotFoundException("Tuner not found.");
- }
-
- public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken)
- {
- if (string.IsNullOrWhiteSpace(channelId))
- {
- throw new ArgumentNullException(nameof(channelId));
- }
-
- foreach (var hostInstance in _tunerHostManager.TunerHosts)
- {
- try
- {
- var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false);
-
- if (sources.Count > 0)
- {
- return sources;
- }
- }
- catch (NotImplementedException)
- {
- }
- }
-
- throw new NotImplementedException();
- }
-
- public Task CloseLiveStream(string id, CancellationToken cancellationToken)
- {
- return Task.CompletedTask;
- }
-
- public Task ResetTuner(string id, CancellationToken cancellationToken)
- {
- return Task.CompletedTask;
- }
-
- private async void OnTimerProviderTimerFired(object sender, GenericEventArgs<TimerInfo> e)
- {
- var timer = e.Argument;
-
- _logger.LogInformation("Recording timer fired for {0}.", timer.Name);
-
- try
- {
- var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds);
-
- if (recordingEndDate <= DateTime.UtcNow)
- {
- _logger.LogWarning("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id);
- OnTimerOutOfDate(timer);
- return;
- }
-
- var activeRecordingInfo = new ActiveRecordingInfo
- {
- CancellationTokenSource = new CancellationTokenSource(),
- Timer = timer,
- Id = timer.Id
- };
-
- if (!_activeRecordings.ContainsKey(timer.Id))
- {
- await RecordStream(timer, recordingEndDate, activeRecordingInfo).ConfigureAwait(false);
- }
- else
- {
- _logger.LogInformation("Skipping RecordStream because it's already in progress.");
- }
- }
- catch (OperationCanceledException)
- {
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error recording stream");
- }
- }
-
- private string GetRecordingPath(TimerInfo timer, RemoteSearchResult metadata, out string seriesPath)
- {
- var recordPath = RecordingPath;
- var config = _config.GetLiveTvConfiguration();
- seriesPath = null;
-
- if (timer.IsProgramSeries)
- {
- var customRecordingPath = config.SeriesRecordingPath;
- var allowSubfolder = true;
- if (!string.IsNullOrWhiteSpace(customRecordingPath))
- {
- allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase);
- recordPath = customRecordingPath;
- }
-
- if (allowSubfolder && config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "Series");
- }
-
- // trim trailing period from the folder name
- var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim();
-
- if (metadata is not null && metadata.ProductionYear.HasValue)
- {
- folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
- }
-
- // Can't use the year here in the folder name because it is the year of the episode, not the series.
- recordPath = Path.Combine(recordPath, folderName);
-
- seriesPath = recordPath;
-
- if (timer.SeasonNumber.HasValue)
- {
- folderName = string.Format(
- CultureInfo.InvariantCulture,
- "Season {0}",
- timer.SeasonNumber.Value);
- recordPath = Path.Combine(recordPath, folderName);
- }
- }
- else if (timer.IsMovie)
- {
- var customRecordingPath = config.MovieRecordingPath;
- var allowSubfolder = true;
- if (!string.IsNullOrWhiteSpace(customRecordingPath))
- {
- allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase);
- recordPath = customRecordingPath;
- }
-
- if (allowSubfolder && config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "Movies");
- }
-
- var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
- if (timer.ProductionYear.HasValue)
- {
- folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
- }
-
- // trim trailing period from the folder name
- folderName = folderName.TrimEnd('.').Trim();
-
- recordPath = Path.Combine(recordPath, folderName);
- }
- else if (timer.IsKids)
- {
- if (config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "Kids");
- }
-
- var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
- if (timer.ProductionYear.HasValue)
- {
- folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
- }
-
- // trim trailing period from the folder name
- folderName = folderName.TrimEnd('.').Trim();
-
- recordPath = Path.Combine(recordPath, folderName);
- }
- else if (timer.IsSports)
- {
- if (config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "Sports");
- }
-
- recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim());
- }
- else
- {
- if (config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "Other");
- }
-
- recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim());
- }
-
- var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts";
-
- return Path.Combine(recordPath, recordingFileName);
- }
-
- private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo)
- {
- ArgumentNullException.ThrowIfNull(timer);
-
- LiveTvProgram programInfo = null;
-
- if (!string.IsNullOrWhiteSpace(timer.ProgramId))
- {
- programInfo = GetProgramInfoFromCache(timer);
- }
-
- if (programInfo is null)
- {
- _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId);
- programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate);
- }
-
- if (programInfo is not null)
- {
- CopyProgramInfoToTimerInfo(programInfo, timer);
- }
-
- var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false);
- var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath);
-
- var channelItem = _liveTvManager.GetLiveTvChannel(timer, this);
-
- string liveStreamId = null;
- RecordingStatus recordingStatus;
- try
- {
- var allMediaSources = await _mediaSourceManager.GetPlaybackMediaSources(channelItem, null, true, false, CancellationToken.None).ConfigureAwait(false);
-
- var mediaStreamInfo = allMediaSources[0];
- IDirectStreamProvider directStreamProvider = null;
-
- if (mediaStreamInfo.RequiresOpening)
- {
- var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal(
- new LiveStreamRequest
- {
- ItemId = channelItem.Id,
- OpenToken = mediaStreamInfo.OpenToken
- },
- CancellationToken.None).ConfigureAwait(false);
-
- mediaStreamInfo = liveStreamResponse.Item1.MediaSource;
- liveStreamId = mediaStreamInfo.LiveStreamId;
- directStreamProvider = liveStreamResponse.Item2;
- }
-
- using var recorder = GetRecorder(mediaStreamInfo);
-
- recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath);
- recordPath = EnsureFileUnique(recordPath, timer.Id);
-
- _libraryMonitor.ReportFileSystemChangeBeginning(recordPath);
-
- var duration = recordingEndDate - DateTime.UtcNow;
-
- _logger.LogInformation("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture));
-
- _logger.LogInformation("Writing file to: {Path}", recordPath);
-
- Action onStarted = async () =>
- {
- activeRecordingInfo.Path = recordPath;
-
- _activeRecordings.TryAdd(timer.Id, activeRecordingInfo);
-
- timer.Status = RecordingStatus.InProgress;
- _timerProvider.AddOrUpdate(timer, false);
-
- await SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false);
-
- await CreateRecordingFolders().ConfigureAwait(false);
-
- TriggerRefresh(recordPath);
- await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false);
- };
-
- await recorder.Record(directStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false);
-
- recordingStatus = RecordingStatus.Completed;
- _logger.LogInformation("Recording completed: {RecordPath}", recordPath);
- }
- catch (OperationCanceledException)
- {
- _logger.LogInformation("Recording stopped: {RecordPath}", recordPath);
- recordingStatus = RecordingStatus.Completed;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error recording to {RecordPath}", recordPath);
- recordingStatus = RecordingStatus.Error;
- }
-
- if (!string.IsNullOrWhiteSpace(liveStreamId))
- {
- try
- {
- await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error closing live stream");
- }
- }
-
- DeleteFileIfEmpty(recordPath);
-
- TriggerRefresh(recordPath);
- _libraryMonitor.ReportFileSystemChangeComplete(recordPath, false);
-
- _activeRecordings.TryRemove(timer.Id, out _);
-
- if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10)
- {
- const int RetryIntervalSeconds = 60;
- _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds);
-
- timer.Status = RecordingStatus.New;
- timer.PrePaddingSeconds = 0;
- timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds);
- timer.RetryCount++;
- _timerProvider.AddOrUpdate(timer);
- }
- else if (File.Exists(recordPath))
- {
- timer.RecordingPath = recordPath;
- timer.Status = RecordingStatus.Completed;
- _timerProvider.AddOrUpdate(timer, false);
- OnSuccessfulRecording(timer, recordPath);
- }
- else
- {
- _timerProvider.Delete(timer);
- }
- }
-
- private async Task<RemoteSearchResult> FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken)
- {
- if (timer.IsSeries)
- {
- if (timer.SeriesProviderIds.Count == 0)
- {
- return null;
- }
-
- var query = new RemoteSearchQuery<SeriesInfo>()
- {
- SearchInfo = new SeriesInfo
- {
- ProviderIds = timer.SeriesProviderIds,
- Name = timer.Name,
- MetadataCountryCode = _config.Configuration.MetadataCountryCode,
- MetadataLanguage = _config.Configuration.PreferredMetadataLanguage
- }
- };
-
- var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, cancellationToken).ConfigureAwait(false);
-
- return results.FirstOrDefault();
- }
-
- return null;
- }
-
- private void DeleteFileIfEmpty(string path)
- {
- var file = _fileSystem.GetFileInfo(path);
-
- if (file.Exists && file.Length == 0)
- {
- try
- {
- _fileSystem.DeleteFile(path);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path);
- }
- }
- }
-
- private void TriggerRefresh(string path)
- {
- _logger.LogInformation("Triggering refresh on {Path}", path);
-
- var item = GetAffectedBaseItem(Path.GetDirectoryName(path));
-
- if (item is not null)
- {
- _logger.LogInformation("Refreshing recording parent {Path}", item.Path);
-
- _providerManager.QueueRefresh(
- item.Id,
- new MetadataRefreshOptions(new DirectoryService(_fileSystem))
- {
- RefreshPaths = new string[]
- {
- path,
- Path.GetDirectoryName(path),
- Path.GetDirectoryName(Path.GetDirectoryName(path))
- }
- },
- RefreshPriority.High);
- }
- }
-
- private BaseItem GetAffectedBaseItem(string path)
- {
- BaseItem item = null;
-
- var parentPath = Path.GetDirectoryName(path);
-
- while (item is null && !string.IsNullOrEmpty(path))
- {
- item = _libraryManager.FindByPath(path, null);
-
- path = Path.GetDirectoryName(path);
- }
-
- if (item is not null)
- {
- if (item.GetType() == typeof(Folder) && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase))
- {
- var parentItem = item.GetParent();
- if (parentItem is not null && parentItem is not AggregateFolder)
- {
- item = parentItem;
- }
- }
- }
-
- return item;
- }
-
- private async Task EnforceKeepUpTo(TimerInfo timer, string seriesPath)
- {
- if (string.IsNullOrWhiteSpace(timer.SeriesTimerId))
- {
- return;
- }
-
- if (string.IsNullOrWhiteSpace(seriesPath))
- {
- return;
- }
-
- var seriesTimerId = timer.SeriesTimerId;
- var seriesTimer = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase));
-
- if (seriesTimer is null || seriesTimer.KeepUpTo <= 0)
- {
- return;
- }
-
- if (_disposed)
- {
- return;
- }
-
- await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false);
-
- try
- {
- if (_disposed)
- {
- return;
- }
-
- var timersToDelete = _timerProvider.GetAll()
- .Where(i => i.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(i.RecordingPath))
- .Where(i => string.Equals(i.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase))
- .OrderByDescending(i => i.EndDate)
- .Where(i => File.Exists(i.RecordingPath))
- .Skip(seriesTimer.KeepUpTo - 1)
- .ToList();
-
- DeleteLibraryItemsForTimers(timersToDelete);
-
- if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries)
- {
- return;
- }
-
- var episodesToDelete = librarySeries.GetItemList(
- new InternalItemsQuery
- {
- OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) },
- IsVirtualItem = false,
- IsFolder = false,
- Recursive = true,
- DtoOptions = new DtoOptions(true)
- })
- .Where(i => i.IsFileProtocol && File.Exists(i.Path))
- .Skip(seriesTimer.KeepUpTo - 1)
- .ToList();
-
- foreach (var item in episodesToDelete)
- {
- try
- {
- _libraryManager.DeleteItem(
- item,
- new DeleteOptions
- {
- DeleteFileLocation = true
- },
- true);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error deleting item");
- }
- }
- }
- finally
- {
- _recordingDeleteSemaphore.Release();
- }
- }
-
- private void DeleteLibraryItemsForTimers(List<TimerInfo> timers)
- {
- foreach (var timer in timers)
- {
- if (_disposed)
- {
- return;
- }
-
- try
- {
- DeleteLibraryItemForTimer(timer);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error deleting recording");
- }
- }
- }
-
- private void DeleteLibraryItemForTimer(TimerInfo timer)
- {
- var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false);
-
- if (libraryItem is not null)
- {
- _libraryManager.DeleteItem(
- libraryItem,
- new DeleteOptions
- {
- DeleteFileLocation = true
- },
- true);
- }
- else if (File.Exists(timer.RecordingPath))
- {
- _fileSystem.DeleteFile(timer.RecordingPath);
- }
-
- _timerProvider.Delete(timer);
- }
-
- private string EnsureFileUnique(string path, string timerId)
- {
- var originalPath = path;
- var index = 1;
-
- while (FileExists(path, timerId))
- {
- var parent = Path.GetDirectoryName(originalPath);
- var name = Path.GetFileNameWithoutExtension(originalPath);
- name += " - " + index.ToString(CultureInfo.InvariantCulture);
-
- path = Path.ChangeExtension(Path.Combine(parent, name), Path.GetExtension(originalPath));
- index++;
- }
-
- return path;
- }
-
- private bool FileExists(string path, string timerId)
- {
- if (File.Exists(path))
- {
- return true;
- }
-
- return _activeRecordings
- .Any(i => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase));
- }
-
- private IRecorder GetRecorder(MediaSourceInfo mediaSource)
- {
- if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http))
- {
- return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config);
- }
-
- return new DirectRecorder(_logger, _httpClientFactory, _streamHelper);
- }
-
- private void OnSuccessfulRecording(TimerInfo timer, string path)
- {
- PostProcessRecording(timer, path);
- }
-
- private void PostProcessRecording(TimerInfo timer, string path)
- {
- var options = _config.GetLiveTvConfiguration();
- if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor))
- {
- return;
- }
-
- try
- {
- var process = new Process
- {
- StartInfo = new ProcessStartInfo
- {
- Arguments = GetPostProcessArguments(path, options.RecordingPostProcessorArguments),
- CreateNoWindow = true,
- ErrorDialog = false,
- FileName = options.RecordingPostProcessor,
- WindowStyle = ProcessWindowStyle.Hidden,
- UseShellExecute = false
- },
- EnableRaisingEvents = true
- };
-
- _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
-
- process.Exited += OnProcessExited;
- process.Start();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error running recording post processor");
- }
- }
-
- private static string GetPostProcessArguments(string path, string arguments)
- {
- return arguments.Replace("{path}", path, StringComparison.OrdinalIgnoreCase);
- }
-
- private void OnProcessExited(object sender, EventArgs e)
- {
- using (var process = (Process)sender)
- {
- _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode);
- }
- }
-
- private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image)
- {
- if (!image.IsLocalFile)
- {
- image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false);
- }
-
- string imageSaveFilenameWithoutExtension = image.Type switch
- {
- ImageType.Primary => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "poster",
- ImageType.Logo => "logo",
- ImageType.Thumb => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "landscape",
- ImageType.Backdrop => "fanart",
- _ => null
- };
-
- if (imageSaveFilenameWithoutExtension is null)
- {
- return;
- }
-
- var imageSavePath = Path.Combine(Path.GetDirectoryName(recordingPath), imageSaveFilenameWithoutExtension);
-
- // preserve original image extension
- imageSavePath = Path.ChangeExtension(imageSavePath, Path.GetExtension(image.Path));
-
- File.Copy(image.Path, imageSavePath, true);
- }
-
- private async Task SaveRecordingImages(string recordingPath, LiveTvProgram program)
- {
- var image = program.IsSeries ?
- (program.GetImageInfo(ImageType.Thumb, 0) ?? program.GetImageInfo(ImageType.Primary, 0)) :
- (program.GetImageInfo(ImageType.Primary, 0) ?? program.GetImageInfo(ImageType.Thumb, 0));
-
- if (image is not null)
- {
- try
- {
- await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error saving recording image");
- }
- }
-
- if (!program.IsSeries)
- {
- image = program.GetImageInfo(ImageType.Backdrop, 0);
- if (image is not null)
- {
- try
- {
- await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error saving recording image");
- }
- }
-
- image = program.GetImageInfo(ImageType.Thumb, 0);
- if (image is not null)
- {
- try
- {
- await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error saving recording image");
- }
- }
-
- image = program.GetImageInfo(ImageType.Logo, 0);
- if (image is not null)
- {
- try
- {
- await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error saving recording image");
- }
- }
- }
- }
-
- private async Task SaveRecordingMetadata(TimerInfo timer, string recordingPath, string seriesPath)
- {
- try
- {
- var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
- Limit = 1,
- ExternalId = timer.ProgramId,
- DtoOptions = new DtoOptions(true)
- }).FirstOrDefault() as LiveTvProgram;
-
- // dummy this up
- if (program is null)
- {
- program = new LiveTvProgram
- {
- Name = timer.Name,
- Overview = timer.Overview,
- Genres = timer.Genres,
- CommunityRating = timer.CommunityRating,
- OfficialRating = timer.OfficialRating,
- ProductionYear = timer.ProductionYear,
- PremiereDate = timer.OriginalAirDate,
- IndexNumber = timer.EpisodeNumber,
- ParentIndexNumber = timer.SeasonNumber
- };
- }
-
- if (timer.IsSports)
- {
- program.AddGenre("Sports");
- }
-
- if (timer.IsKids)
- {
- program.AddGenre("Kids");
- program.AddGenre("Children");
- }
-
- if (timer.IsNews)
- {
- program.AddGenre("News");
- }
-
- var config = _config.GetLiveTvConfiguration();
-
- if (config.SaveRecordingNFO)
- {
- if (timer.IsProgramSeries)
- {
- await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false);
- await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
- }
- else if (!timer.IsMovie || timer.IsSports || timer.IsNews)
- {
- await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false);
- }
- else
- {
- await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
- }
- }
-
- if (config.SaveRecordingImages)
- {
- await SaveRecordingImages(recordingPath, program).ConfigureAwait(false);
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error saving nfo");
- }
- }
-
- private async Task SaveSeriesNfoAsync(TimerInfo timer, string seriesPath)
- {
- var nfoPath = Path.Combine(seriesPath, "tvshow.nfo");
-
- if (File.Exists(nfoPath))
- {
- return;
- }
-
- var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
- await using (stream.ConfigureAwait(false))
- {
- var settings = new XmlWriterSettings
- {
- Indent = true,
- Encoding = Encoding.UTF8,
- Async = true
- };
-
- var writer = XmlWriter.Create(stream, settings);
- await using (writer.ConfigureAwait(false))
- {
- await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
- await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false);
- if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var id))
- {
- await writer.WriteElementStringAsync(null, "id", null, id).ConfigureAwait(false);
- }
-
- if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out id))
- {
- await writer.WriteElementStringAsync(null, "imdb_id", null, id).ConfigureAwait(false);
- }
-
- if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out id))
- {
- await writer.WriteElementStringAsync(null, "tmdbid", null, id).ConfigureAwait(false);
- }
-
- if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out id))
- {
- await writer.WriteElementStringAsync(null, "zap2itid", null, id).ConfigureAwait(false);
- }
-
- if (!string.IsNullOrWhiteSpace(timer.Name))
- {
- await writer.WriteElementStringAsync(null, "title", null, timer.Name).ConfigureAwait(false);
- }
-
- if (!string.IsNullOrWhiteSpace(timer.OfficialRating))
- {
- await writer.WriteElementStringAsync(null, "mpaa", null, timer.OfficialRating).ConfigureAwait(false);
- }
-
- foreach (var genre in timer.Genres)
- {
- await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false);
- }
-
- await writer.WriteEndElementAsync().ConfigureAwait(false);
- await writer.WriteEndDocumentAsync().ConfigureAwait(false);
- }
- }
- }
-
- private async Task SaveVideoNfoAsync(TimerInfo timer, string recordingPath, BaseItem item, bool lockData)
- {
- var nfoPath = Path.ChangeExtension(recordingPath, ".nfo");
-
- if (File.Exists(nfoPath))
- {
- return;
- }
-
- var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
- await using (stream.ConfigureAwait(false))
- {
- var settings = new XmlWriterSettings
- {
- Indent = true,
- Encoding = Encoding.UTF8,
- Async = true
- };
-
- var options = _config.GetNfoConfiguration();
-
- var isSeriesEpisode = timer.IsProgramSeries;
-
- var writer = XmlWriter.Create(stream, settings);
- await using (writer.ConfigureAwait(false))
- {
- await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
-
- if (isSeriesEpisode)
- {
- await writer.WriteStartElementAsync(null, "episodedetails", null).ConfigureAwait(false);
-
- if (!string.IsNullOrWhiteSpace(timer.EpisodeTitle))
- {
- await writer.WriteElementStringAsync(null, "title", null, timer.EpisodeTitle).ConfigureAwait(false);
- }
-
- var premiereDate = item.PremiereDate ?? (!timer.IsRepeat ? DateTime.UtcNow : null);
-
- if (premiereDate.HasValue)
- {
- var formatString = options.ReleaseDateFormat;
-
- await writer.WriteElementStringAsync(
- null,
- "aired",
- null,
- premiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
- }
-
- if (item.IndexNumber.HasValue)
- {
- await writer.WriteElementStringAsync(null, "episode", null, item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
- }
-
- if (item.ParentIndexNumber.HasValue)
- {
- await writer.WriteElementStringAsync(null, "season", null, item.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
- }
- }
- else
- {
- await writer.WriteStartElementAsync(null, "movie", null).ConfigureAwait(false);
-
- if (!string.IsNullOrWhiteSpace(item.Name))
- {
- await writer.WriteElementStringAsync(null, "title", null, item.Name).ConfigureAwait(false);
- }
-
- if (!string.IsNullOrWhiteSpace(item.OriginalTitle))
- {
- await writer.WriteElementStringAsync(null, "originaltitle", null, item.OriginalTitle).ConfigureAwait(false);
- }
-
- if (item.PremiereDate.HasValue)
- {
- var formatString = options.ReleaseDateFormat;
-
- await writer.WriteElementStringAsync(
- null,
- "premiered",
- null,
- item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
- await writer.WriteElementStringAsync(
- null,
- "releasedate",
- null,
- item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
- }
- }
-
- await writer.WriteElementStringAsync(
- null,
- "dateadded",
- null,
- DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false);
-
- if (item.ProductionYear.HasValue)
- {
- await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
- }
-
- if (!string.IsNullOrEmpty(item.OfficialRating))
- {
- await writer.WriteElementStringAsync(null, "mpaa", null, item.OfficialRating).ConfigureAwait(false);
- }
-
- var overview = (item.Overview ?? string.Empty)
- .StripHtml()
- .Replace("&quot;", "'", StringComparison.Ordinal);
-
- await writer.WriteElementStringAsync(null, "plot", null, overview).ConfigureAwait(false);
-
- if (item.CommunityRating.HasValue)
- {
- await writer.WriteElementStringAsync(null, "rating", null, item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
- }
-
- foreach (var genre in item.Genres)
- {
- await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false);
- }
-
- var people = item.Id.IsEmpty() ? new List<PersonInfo>() : _libraryManager.GetPeople(item);
-
- var directors = people
- .Where(i => i.IsType(PersonKind.Director))
- .Select(i => i.Name)
- .ToList();
-
- foreach (var person in directors)
- {
- await writer.WriteElementStringAsync(null, "director", null, person).ConfigureAwait(false);
- }
-
- var writers = people
- .Where(i => i.IsType(PersonKind.Writer))
- .Select(i => i.Name)
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .ToList();
-
- foreach (var person in writers)
- {
- await writer.WriteElementStringAsync(null, "writer", null, person).ConfigureAwait(false);
- }
-
- foreach (var person in writers)
- {
- await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false);
- }
-
- var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection);
-
- if (!string.IsNullOrEmpty(tmdbCollection))
- {
- await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false);
- }
-
- var imdb = item.GetProviderId(MetadataProvider.Imdb);
- if (!string.IsNullOrEmpty(imdb))
- {
- if (!isSeriesEpisode)
- {
- await writer.WriteElementStringAsync(null, "id", null, imdb).ConfigureAwait(false);
- }
-
- await writer.WriteElementStringAsync(null, "imdbid", null, imdb).ConfigureAwait(false);
-
- // No need to lock if we have identified the content already
- lockData = false;
- }
-
- var tvdb = item.GetProviderId(MetadataProvider.Tvdb);
- if (!string.IsNullOrEmpty(tvdb))
- {
- await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false);
-
- // No need to lock if we have identified the content already
- lockData = false;
- }
-
- var tmdb = item.GetProviderId(MetadataProvider.Tmdb);
- if (!string.IsNullOrEmpty(tmdb))
- {
- await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false);
-
- // No need to lock if we have identified the content already
- lockData = false;
- }
-
- if (lockData)
- {
- await writer.WriteElementStringAsync(null, "lockdata", null, "true").ConfigureAwait(false);
- }
-
- if (item.CriticRating.HasValue)
- {
- await writer.WriteElementStringAsync(null, "criticrating", null, item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
- }
-
- if (!string.IsNullOrWhiteSpace(item.Tagline))
- {
- await writer.WriteElementStringAsync(null, "tagline", null, item.Tagline).ConfigureAwait(false);
- }
-
- foreach (var studio in item.Studios)
- {
- await writer.WriteElementStringAsync(null, "studio", null, studio).ConfigureAwait(false);
- }
-
- await writer.WriteEndElementAsync().ConfigureAwait(false);
- await writer.WriteEndDocumentAsync().ConfigureAwait(false);
- }
- }
- }
-
- private LiveTvProgram GetProgramInfoFromCache(string programId)
- {
- var query = new InternalItemsQuery
- {
- ItemIds = new[] { _liveTvManager.GetInternalProgramId(programId) },
- Limit = 1,
- DtoOptions = new DtoOptions()
- };
-
- return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().FirstOrDefault();
- }
-
- private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer)
- {
- return GetProgramInfoFromCache(timer.ProgramId);
- }
-
- private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc)
- {
- var query = new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
- Limit = 1,
- DtoOptions = new DtoOptions(true)
- {
- EnableImages = false
- },
- MinStartDate = startDateUtc.AddMinutes(-3),
- MaxStartDate = startDateUtc.AddMinutes(3),
- OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) }
- };
-
- if (!string.IsNullOrWhiteSpace(channelId))
- {
- query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, channelId) };
- }
-
- return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().FirstOrDefault();
- }
-
- private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer)
- {
- if (timer.IsManual)
- {
- return false;
- }
-
- if (!seriesTimer.RecordAnyTime
- && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks)
- {
- return true;
- }
-
- if (seriesTimer.RecordNewOnly && timer.IsRepeat)
- {
- return true;
- }
-
- if (!seriesTimer.RecordAnyChannel
- && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
-
- return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer);
- }
-
- 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))
- {
- timer.Status = RecordingStatus.Cancelled;
- _timerProvider.Update(timer);
- }
- }
-
- private void SearchForDuplicateShowIds(IEnumerable<TimerInfo> timers)
- {
- var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList();
-
- foreach (var group in groups)
- {
- if (string.IsNullOrWhiteSpace(group.Key))
- {
- continue;
- }
-
- var groupTimers = group.ToList();
-
- if (groupTimers.Count < 2)
- {
- continue;
- }
-
- // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856
- if (group.Key.EndsWith("0000", StringComparison.Ordinal))
- {
- continue;
- }
-
- HandleDuplicateShowIds(groupTimers);
- }
- }
-
- private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers)
- {
- var allTimers = GetTimersForSeries(seriesTimer).ToList();
-
- var enabledTimersForSeries = new List<TimerInfo>();
- foreach (var timer in allTimers)
- {
- var existingTimer = _timerProvider.GetTimer(timer.Id)
- ?? (string.IsNullOrWhiteSpace(timer.ProgramId)
- ? null
- : _timerProvider.GetTimerByProgramId(timer.ProgramId));
-
- if (existingTimer is null)
- {
- if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer))
- {
- timer.Status = RecordingStatus.Cancelled;
- }
- else
- {
- enabledTimersForSeries.Add(timer);
- }
-
- _timerProvider.Add(timer);
-
- TimerCreated?.Invoke(this, new GenericEventArgs<TimerInfo>(timer));
- }
-
- // Only update if not currently active - test both new timer and existing in case Id's are different
- // Id's could be different if the timer was created manually prior to series timer creation
- else if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _))
- {
- UpdateExistingTimerWithNewMetadata(existingTimer, timer);
-
- // Needed by ShouldCancelTimerForSeriesTimer
- timer.IsManual = existingTimer.IsManual;
-
- if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer))
- {
- existingTimer.Status = RecordingStatus.Cancelled;
- }
- else if (!existingTimer.IsManual)
- {
- existingTimer.Status = RecordingStatus.New;
- }
-
- if (existingTimer.Status != RecordingStatus.Cancelled)
- {
- enabledTimersForSeries.Add(existingTimer);
- }
-
- if (updateTimerSettings)
- {
- existingTimer.KeepUntil = seriesTimer.KeepUntil;
- existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired;
- existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired;
- existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds;
- existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds;
- existingTimer.Priority = seriesTimer.Priority;
- existingTimer.SeriesTimerId = seriesTimer.Id;
- }
-
- existingTimer.SeriesTimerId = seriesTimer.Id;
- _timerProvider.Update(existingTimer);
- }
- }
-
- SearchForDuplicateShowIds(enabledTimersForSeries);
-
- if (deleteInvalidTimers)
- {
- var allTimerIds = allTimers
- .Select(i => i.Id)
- .ToList();
-
- var deleteStatuses = new[]
- {
- RecordingStatus.New
- };
-
- var deletes = _timerProvider.GetAll()
- .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase))
- .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow)
- .Where(i => deleteStatuses.Contains(i.Status))
- .ToList();
-
- foreach (var timer in deletes)
- {
- CancelTimerInternal(timer.Id, false, false);
- }
- }
- }
-
- private IEnumerable<TimerInfo> GetTimersForSeries(SeriesTimerInfo seriesTimer)
- {
- ArgumentNullException.ThrowIfNull(seriesTimer);
-
- var query = new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
- ExternalSeriesId = seriesTimer.SeriesId,
- DtoOptions = new DtoOptions(true)
- {
- EnableImages = false
- },
- MinEndDate = DateTime.UtcNow
- };
-
- if (string.IsNullOrEmpty(seriesTimer.SeriesId))
- {
- query.Name = seriesTimer.Name;
- }
-
- if (!seriesTimer.RecordAnyChannel)
- {
- query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, seriesTimer.ChannelId) };
- }
-
- var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
-
- return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().Select(i => CreateTimer(i, seriesTimer, tempChannelCache));
- }
-
- private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary<Guid, LiveTvChannel> tempChannelCache)
- {
- string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId;
-
- if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.IsEmpty())
- {
- if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel))
- {
- channel = _libraryManager.GetItemList(
- new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel },
- ItemIds = new[] { parent.ChannelId },
- DtoOptions = new DtoOptions()
- }).FirstOrDefault() as LiveTvChannel;
-
- if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId))
- {
- tempChannelCache[parent.ChannelId] = channel;
- }
- }
-
- if (channel is not null || tempChannelCache.TryGetValue(parent.ChannelId, out channel))
- {
- channelId = channel.ExternalId;
- }
- }
-
- var timer = new TimerInfo
- {
- ChannelId = channelId,
- Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture),
- StartDate = parent.StartDate,
- EndDate = parent.EndDate.Value,
- ProgramId = parent.ExternalId,
- PrePaddingSeconds = seriesTimer.PrePaddingSeconds,
- PostPaddingSeconds = seriesTimer.PostPaddingSeconds,
- IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired,
- IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired,
- KeepUntil = seriesTimer.KeepUntil,
- Priority = seriesTimer.Priority,
- Name = parent.Name,
- Overview = parent.Overview,
- SeriesId = parent.ExternalSeriesId,
- SeriesTimerId = seriesTimer.Id,
- ShowId = parent.ShowId
- };
-
- CopyProgramInfoToTimerInfo(parent, timer, tempChannelCache);
-
- return timer;
- }
-
- private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo)
- {
- var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
- CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache);
- }
-
- private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary<Guid, LiveTvChannel> tempChannelCache)
- {
- string channelId = null;
-
- if (!programInfo.ChannelId.IsEmpty())
- {
- if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel))
- {
- channel = _libraryManager.GetItemList(
- new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel },
- ItemIds = new[] { programInfo.ChannelId },
- DtoOptions = new DtoOptions()
- }).FirstOrDefault() as LiveTvChannel;
-
- if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId))
- {
- tempChannelCache[programInfo.ChannelId] = channel;
- }
- }
-
- if (channel is not null || tempChannelCache.TryGetValue(programInfo.ChannelId, out channel))
- {
- channelId = channel.ExternalId;
- }
- }
-
- timerInfo.Name = programInfo.Name;
- timerInfo.StartDate = programInfo.StartDate;
- timerInfo.EndDate = programInfo.EndDate.Value;
-
- if (!string.IsNullOrWhiteSpace(channelId))
- {
- timerInfo.ChannelId = channelId;
- }
-
- timerInfo.SeasonNumber = programInfo.ParentIndexNumber;
- timerInfo.EpisodeNumber = programInfo.IndexNumber;
- timerInfo.IsMovie = programInfo.IsMovie;
- timerInfo.ProductionYear = programInfo.ProductionYear;
- timerInfo.EpisodeTitle = programInfo.EpisodeTitle;
- timerInfo.OriginalAirDate = programInfo.PremiereDate;
- timerInfo.IsProgramSeries = programInfo.IsSeries;
-
- timerInfo.IsSeries = programInfo.IsSeries;
-
- timerInfo.CommunityRating = programInfo.CommunityRating;
- timerInfo.Overview = programInfo.Overview;
- timerInfo.OfficialRating = programInfo.OfficialRating;
- timerInfo.IsRepeat = programInfo.IsRepeat;
- timerInfo.SeriesId = programInfo.ExternalSeriesId;
- timerInfo.ProviderIds = programInfo.ProviderIds;
- timerInfo.Tags = programInfo.Tags;
-
- var seriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
-
- foreach (var providerId in timerInfo.ProviderIds)
- {
- const string Search = "Series";
- if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase))
- {
- seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value;
- }
- }
-
- timerInfo.SeriesProviderIds = seriesProviderIds;
- }
-
- private bool IsProgramAlreadyInLibrary(TimerInfo program)
- {
- if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle))
- {
- var seriesIds = _libraryManager.GetItemIds(
- new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.Series },
- Name = program.Name
- }).ToArray();
-
- if (seriesIds.Length == 0)
- {
- return false;
- }
-
- if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue)
- {
- var result = _libraryManager.GetItemIds(new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.Episode },
- ParentIndexNumber = program.SeasonNumber.Value,
- IndexNumber = program.EpisodeNumber.Value,
- AncestorIds = seriesIds,
- IsVirtualItem = false,
- Limit = 1
- });
-
- if (result.Count > 0)
- {
- return true;
- }
- }
- }
-
- return false;
- }
-
- /// <inheritdoc />
- public void Dispose()
- {
- if (_disposed)
- {
- return;
- }
-
- _recordingDeleteSemaphore.Dispose();
-
- foreach (var pair in _activeRecordings.ToList())
- {
- pair.Value.CancellationTokenSource.Cancel();
- }
-
- _disposed = true;
- }
-
- public IEnumerable<VirtualFolderInfo> GetRecordingFolders()
- {
- var defaultFolder = RecordingPath;
- var defaultName = "Recordings";
-
- if (Directory.Exists(defaultFolder))
- {
- yield return new VirtualFolderInfo
- {
- Locations = new string[] { defaultFolder },
- Name = defaultName
- };
- }
-
- var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath;
- if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath))
- {
- yield return new VirtualFolderInfo
- {
- Locations = new string[] { customPath },
- Name = "Recorded Movies",
- CollectionType = CollectionTypeOptions.Movies
- };
- }
-
- customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath;
- if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath))
- {
- yield return new VirtualFolderInfo
- {
- Locations = new string[] { customPath },
- Name = "Recorded Shows",
- CollectionType = CollectionTypeOptions.TvShows
- };
- }
- }
- }
-}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs b/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs
deleted file mode 100644
index e750c05ac..000000000
--- a/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs
+++ /dev/null
@@ -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()
- {
- }
- }
-}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs b/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs
deleted file mode 100644
index e8570f0e0..000000000
--- a/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Model.Configuration;
-
-namespace Jellyfin.LiveTv.EmbyTV
-{
- /// <summary>
- /// Class containing extension methods for working with the nfo configuration.
- /// </summary>
- public static class NfoConfigurationExtensions
- {
- /// <summary>
- /// Gets the nfo configuration.
- /// </summary>
- /// <param name="configurationManager">The configuration manager.</param>
- /// <returns>The nfo configuration.</returns>
- public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager)
- => configurationManager.GetConfiguration<XbmcMetadataOptions>("xbmcmetadata");
- }
-}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs b/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs
deleted file mode 100644
index 2ebe60b29..000000000
--- a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using MediaBrowser.Controller.LiveTv;
-using Microsoft.Extensions.Logging;
-
-namespace Jellyfin.LiveTv.EmbyTV
-{
- public class SeriesTimerManager : ItemDataProvider<SeriesTimerInfo>
- {
- public SeriesTimerManager(ILogger logger, string dataPath)
- : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
- {
- }
-
- /// <inheritdoc />
- public override void Add(SeriesTimerInfo item)
- {
- ArgumentException.ThrowIfNullOrEmpty(item.Id);
-
- base.Add(item);
- }
- }
-}
diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
index 5490547ec..73729c950 100644
--- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
+++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
@@ -1,4 +1,9 @@
using Jellyfin.LiveTv.Channels;
+using Jellyfin.LiveTv.Guide;
+using Jellyfin.LiveTv.IO;
+using Jellyfin.LiveTv.Listings;
+using Jellyfin.LiveTv.Recordings;
+using Jellyfin.LiveTv.Timers;
using Jellyfin.LiveTv.TunerHosts;
using Jellyfin.LiveTv.TunerHosts.HdHomerun;
using MediaBrowser.Controller.Channels;
@@ -20,12 +25,22 @@ public static class LiveTvServiceCollectionExtensions
public static void AddLiveTvServices(this IServiceCollection services)
{
services.AddSingleton<LiveTvDtoService>();
+ services.AddSingleton<TimerManager>();
+ services.AddSingleton<SeriesTimerManager>();
+ services.AddSingleton<RecordingsMetadataManager>();
+
services.AddSingleton<ILiveTvManager, LiveTvManager>();
services.AddSingleton<IChannelManager, ChannelManager>();
services.AddSingleton<IStreamHelper, StreamHelper>();
services.AddSingleton<ITunerHostManager, TunerHostManager>();
+ services.AddSingleton<IListingsManager, ListingsManager>();
+ services.AddSingleton<IGuideManager, GuideManager>();
+ services.AddSingleton<IRecordingsManager, RecordingsManager>();
+ services.AddSingleton<ILiveTvService, DefaultLiveTvService>();
services.AddSingleton<ITunerHost, HdHomerunHost>();
services.AddSingleton<ITunerHost, M3UTunerHost>();
+ services.AddSingleton<IListingsProvider, SchedulesDirect>();
+ services.AddSingleton<IListingsProvider, XmlTvListingsProvider>();
}
}
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
new file mode 100644
index 000000000..39f174cc2
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -0,0 +1,711 @@
+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 IRecordingsManager _recordingsManager;
+ 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="recordingsManager">The <see cref="IRecordingsManager"/>.</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,
+ IRecordingsManager recordingsManager,
+ LiveTvDtoService tvDtoService)
+ {
+ _logger = logger;
+ _config = config;
+ _fileSystem = fileSystem;
+ _itemRepo = itemRepo;
+ _libraryManager = libraryManager;
+ _liveTvManager = liveTvManager;
+ _tunerHostManager = tunerHostManager;
+ _recordingsManager = recordingsManager;
+ _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 _recordingsManager.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<DefaultLiveTvService>().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);
+ }
+}
diff --git a/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs
new file mode 100644
index 000000000..a9fde0850
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs
@@ -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
+ }
+ };
+ }
+}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs b/src/Jellyfin.LiveTv/IO/DirectRecorder.cs
index 2a25218b6..c4ec6de40 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs
+++ b/src/Jellyfin.LiveTv/IO/DirectRecorder.cs
@@ -12,7 +12,7 @@ using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.IO
{
public sealed class DirectRecorder : IRecorder
{
diff --git a/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
index 132a5fc51..ff00c8999 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs
+++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
@@ -23,7 +23,7 @@ using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.IO
{
public class EncodedRecorder : IRecorder
{
diff --git a/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs b/src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs
index 9d442e20c..394b9cf11 100644
--- a/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs
+++ b/src/Jellyfin.LiveTv/IO/ExclusiveLiveStream.cs
@@ -11,7 +11,7 @@ using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
-namespace Jellyfin.LiveTv
+namespace Jellyfin.LiveTv.IO
{
public sealed class ExclusiveLiveStream : ILiveStream
{
diff --git a/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs b/src/Jellyfin.LiveTv/IO/IRecorder.cs
index 7ed42e263..ab4506414 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs
+++ b/src/Jellyfin.LiveTv/IO/IRecorder.cs
@@ -6,7 +6,7 @@ using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.IO
{
public interface IRecorder : IDisposable
{
diff --git a/src/Jellyfin.LiveTv/StreamHelper.cs b/src/Jellyfin.LiveTv/IO/StreamHelper.cs
index e9644e95e..7947807ba 100644
--- a/src/Jellyfin.LiveTv/StreamHelper.cs
+++ b/src/Jellyfin.LiveTv/IO/StreamHelper.cs
@@ -7,7 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.IO;
-namespace Jellyfin.LiveTv
+namespace Jellyfin.LiveTv.IO
{
public class StreamHelper : IStreamHelper
{
diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj
index 5a826a1da..c58889740 100644
--- a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj
+++ b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj
@@ -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>
diff --git a/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs b/src/Jellyfin.LiveTv/Listings/EpgChannelData.cs
index 43d308c43..81437f791 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs
+++ b/src/Jellyfin.LiveTv/Listings/EpgChannelData.cs
@@ -4,7 +4,7 @@ using System;
using System.Collections.Generic;
using MediaBrowser.Controller.LiveTv;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.Listings
{
internal class EpgChannelData
{
diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
new file mode 100644
index 000000000..87f47611e
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
@@ -0,0 +1,461 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+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;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.Listings;
+
+/// <inheritdoc />
+public class ListingsManager : IListingsManager
+{
+ private readonly ILogger<ListingsManager> _logger;
+ private readonly IConfigurationManager _config;
+ private readonly ITaskManager _taskManager;
+ private readonly ITunerHostManager _tunerHostManager;
+ private readonly IListingsProvider[] _listingsProviders;
+
+ private readonly ConcurrentDictionary<string, EpgChannelData> _epgChannels = new(StringComparer.OrdinalIgnoreCase);
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ListingsManager"/> class.
+ /// </summary>
+ /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param>
+ /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
+ /// <param name="taskManager">The <see cref="ITaskManager"/>.</param>
+ /// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
+ /// <param name="listingsProviders">The <see cref="IListingsProvider"/>.</param>
+ public ListingsManager(
+ ILogger<ListingsManager> logger,
+ IConfigurationManager config,
+ ITaskManager taskManager,
+ ITunerHostManager tunerHostManager,
+ IEnumerable<IListingsProvider> listingsProviders)
+ {
+ _logger = logger;
+ _config = config;
+ _taskManager = taskManager;
+ _tunerHostManager = tunerHostManager;
+ _listingsProviders = listingsProviders.ToArray();
+ }
+
+ /// <inheritdoc />
+ public async Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings)
+ {
+ ArgumentNullException.ThrowIfNull(info);
+
+ var provider = GetProvider(info.Type);
+ await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false);
+
+ var config = _config.GetLiveTvConfiguration();
+
+ var list = config.ListingProviders.ToList();
+ int index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
+ {
+ info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+ list.Add(info);
+ config.ListingProviders = list.ToArray();
+ }
+ else
+ {
+ config.ListingProviders[index] = info;
+ }
+
+ _config.SaveConfiguration("livetv", config);
+ _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
+
+ return info;
+ }
+
+ /// <inheritdoc />
+ public void DeleteListingsProvider(string? id)
+ {
+ var config = _config.GetLiveTvConfiguration();
+
+ config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
+
+ _config.SaveConfiguration("livetv", config);
+ _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
+ }
+
+ /// <inheritdoc />
+ public Task<List<NameIdPair>> GetLineups(string? providerType, string? providerId, string? country, string? location)
+ {
+ if (string.IsNullOrWhiteSpace(providerId))
+ {
+ return GetProvider(providerType).GetLineups(null, country, location);
+ }
+
+ var info = _config.GetLiveTvConfiguration().ListingProviders
+ .FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase))
+ ?? throw new ResourceNotFoundException();
+
+ return GetProvider(info.Type).GetLineups(info, country, location);
+ }
+
+ /// <inheritdoc />
+ public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(
+ ChannelInfo channel,
+ DateTime startDateUtc,
+ DateTime endDateUtc,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(channel);
+
+ foreach (var (provider, providerInfo) in GetListingProviders())
+ {
+ if (!IsListingProviderEnabledForTuner(providerInfo, channel.TunerHostId))
+ {
+ _logger.LogDebug(
+ "Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.",
+ channel.Number,
+ channel.Name,
+ provider.Name,
+ providerInfo.ListingsId ?? string.Empty);
+ continue;
+ }
+
+ _logger.LogDebug(
+ "Getting programs for channel {0}-{1} from {2}-{3}",
+ channel.Number,
+ channel.Name,
+ provider.Name,
+ providerInfo.ListingsId ?? string.Empty);
+
+ var epgChannels = await GetEpgChannels(provider, providerInfo, true, cancellationToken).ConfigureAwait(false);
+
+ var epgChannel = GetEpgChannelFromTunerChannel(providerInfo.ChannelMappings, channel, epgChannels);
+ if (epgChannel is null)
+ {
+ _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Name, providerInfo.ListingsId ?? string.Empty);
+ continue;
+ }
+
+ var programs = (await provider
+ .GetProgramsAsync(providerInfo, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken).ConfigureAwait(false))
+ .ToList();
+
+ // Replace the value that came from the provider with a normalized value
+ foreach (var program in programs)
+ {
+ program.ChannelId = channel.Id;
+ program.Id += "_" + channel.Id;
+ }
+
+ if (programs.Count > 0)
+ {
+ return programs;
+ }
+ }
+
+ return Enumerable.Empty<ProgramInfo>();
+ }
+
+ /// <inheritdoc />
+ public async Task AddProviderMetadata(IList<ChannelInfo> channels, bool enableCache, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(channels);
+
+ foreach (var (provider, providerInfo) in GetListingProviders())
+ {
+ var enabledChannels = channels
+ .Where(i => IsListingProviderEnabledForTuner(providerInfo, i.TunerHostId))
+ .ToList();
+
+ if (enabledChannels.Count == 0)
+ {
+ continue;
+ }
+
+ try
+ {
+ await AddMetadata(provider, providerInfo, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false);
+ }
+ catch (NotSupportedException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error adding metadata");
+ }
+ }
+ }
+
+ /// <inheritdoc />
+ public async Task<ChannelMappingOptionsDto> GetChannelMappingOptions(string? providerId)
+ {
+ var listingsProviderInfo = _config.GetLiveTvConfiguration().ListingProviders
+ .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ var provider = GetProvider(listingsProviderInfo.Type);
+
+ var tunerChannels = await GetChannelsForListingsProvider(listingsProviderInfo, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var providerChannels = await provider.GetChannels(listingsProviderInfo, default)
+ .ConfigureAwait(false);
+
+ var mappings = listingsProviderInfo.ChannelMappings;
+
+ return new ChannelMappingOptionsDto
+ {
+ TunerChannels = tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList(),
+ ProviderChannels = providerChannels.Select(i => new NameIdPair
+ {
+ Name = i.Name,
+ Id = i.Id
+ }).ToList(),
+ Mappings = mappings,
+ ProviderName = provider.Name
+ };
+ }
+
+ /// <inheritdoc />
+ public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber)
+ {
+ var config = _config.GetLiveTvConfiguration();
+
+ var listingsProviderInfo = config.ListingProviders
+ .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings
+ .Where(pair => !string.Equals(pair.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray();
+
+ if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase))
+ {
+ var list = listingsProviderInfo.ChannelMappings.ToList();
+ list.Add(new NameValuePair
+ {
+ Name = tunerChannelNumber,
+ Value = providerChannelNumber
+ });
+ listingsProviderInfo.ChannelMappings = list.ToArray();
+ }
+
+ _config.SaveConfiguration("livetv", config);
+
+ var tunerChannels = await GetChannelsForListingsProvider(listingsProviderInfo, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var providerChannels = await GetProvider(listingsProviderInfo.Type).GetChannels(listingsProviderInfo, default)
+ .ConfigureAwait(false);
+
+ var tunerChannelMappings = tunerChannels
+ .Select(i => GetTunerChannelMapping(i, listingsProviderInfo.ChannelMappings, providerChannels)).ToList();
+
+ _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
+
+ return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private List<(IListingsProvider Provider, ListingsProviderInfo ProviderInfo)> GetListingProviders()
+ => _config.GetLiveTvConfiguration().ListingProviders
+ .Select(info => (
+ Provider: _listingsProviders.FirstOrDefault(l
+ => string.Equals(l.Type, info.Type, StringComparison.OrdinalIgnoreCase)),
+ ProviderInfo: info))
+ .Where(i => i.Provider is not null)
+ .ToList()!; // Already filtered out null
+
+ private async Task AddMetadata(
+ IListingsProvider provider,
+ ListingsProviderInfo info,
+ IEnumerable<ChannelInfo> tunerChannels,
+ bool enableCache,
+ CancellationToken cancellationToken)
+ {
+ var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false);
+
+ foreach (var tunerChannel in tunerChannels)
+ {
+ var epgChannel = GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels);
+ if (epgChannel is null)
+ {
+ continue;
+ }
+
+ if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl))
+ {
+ tunerChannel.ImageUrl = epgChannel.ImageUrl;
+ }
+ }
+ }
+
+ private static bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId)
+ {
+ if (info.EnableAllTuners)
+ {
+ return true;
+ }
+
+ ArgumentException.ThrowIfNullOrWhiteSpace(tunerHostId);
+
+ return info.EnabledTuners.Contains(tunerHostId, StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static string GetMappedChannel(string channelId, NameValuePair[] mappings)
+ {
+ foreach (NameValuePair mapping in mappings)
+ {
+ if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase))
+ {
+ return mapping.Value;
+ }
+ }
+
+ return channelId;
+ }
+
+ private async Task<EpgChannelData> GetEpgChannels(
+ IListingsProvider provider,
+ ListingsProviderInfo info,
+ bool enableCache,
+ CancellationToken cancellationToken)
+ {
+ if (enableCache && _epgChannels.TryGetValue(info.Id, out var result))
+ {
+ return result;
+ }
+
+ var channels = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false);
+ foreach (var channel in channels)
+ {
+ _logger.LogInformation("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name, channel.Id);
+ }
+
+ result = new EpgChannelData(channels);
+ _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result);
+
+ return result;
+ }
+
+ private static ChannelInfo? GetEpgChannelFromTunerChannel(
+ NameValuePair[] mappings,
+ ChannelInfo tunerChannel,
+ EpgChannelData epgChannelData)
+ {
+ if (!string.IsNullOrWhiteSpace(tunerChannel.Id))
+ {
+ var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings);
+ if (string.IsNullOrWhiteSpace(mappedTunerChannelId))
+ {
+ mappedTunerChannelId = tunerChannel.Id;
+ }
+
+ var channel = epgChannelData.GetChannelById(mappedTunerChannelId);
+ if (channel is not null)
+ {
+ return channel;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId))
+ {
+ var tunerChannelId = tunerChannel.TunerChannelId;
+ if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase))
+ {
+ tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I');
+ }
+
+ var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings);
+ if (string.IsNullOrWhiteSpace(mappedTunerChannelId))
+ {
+ mappedTunerChannelId = tunerChannelId;
+ }
+
+ var channel = epgChannelData.GetChannelById(mappedTunerChannelId);
+ if (channel is not null)
+ {
+ return channel;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(tunerChannel.Number))
+ {
+ var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings);
+ if (string.IsNullOrWhiteSpace(tunerChannelNumber))
+ {
+ tunerChannelNumber = tunerChannel.Number;
+ }
+
+ var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber);
+ if (channel is not null)
+ {
+ return channel;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(tunerChannel.Name))
+ {
+ var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name);
+
+ var channel = epgChannelData.GetChannelByName(normalizedName);
+ if (channel is not null)
+ {
+ return channel;
+ }
+ }
+
+ return null;
+ }
+
+ private static TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, IList<ChannelInfo> providerChannels)
+ {
+ var result = new TunerChannelMapping
+ {
+ Name = tunerChannel.Name,
+ Id = tunerChannel.Id
+ };
+
+ if (!string.IsNullOrWhiteSpace(tunerChannel.Number))
+ {
+ result.Name = tunerChannel.Number + " " + result.Name;
+ }
+
+ var providerChannel = GetEpgChannelFromTunerChannel(mappings, tunerChannel, new EpgChannelData(providerChannels));
+ if (providerChannel is not null)
+ {
+ result.ProviderChannelName = providerChannel.Name;
+ result.ProviderChannelId = providerChannel.Id;
+ }
+
+ return result;
+ }
+
+ private async Task<List<ChannelInfo>> GetChannelsForListingsProvider(ListingsProviderInfo info, CancellationToken cancellationToken)
+ {
+ var channels = new List<ChannelInfo>();
+ foreach (var hostInstance in _tunerHostManager.TunerHosts)
+ {
+ try
+ {
+ var tunerChannels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false);
+
+ channels.AddRange(tunerChannels.Where(channel => IsListingProviderEnabledForTuner(info, channel.TunerHostId)));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting channels");
+ }
+ }
+
+ return channels;
+ }
+
+ private IListingsProvider GetProvider(string? providerType)
+ => _listingsProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase))
+ ?? throw new ResourceNotFoundException($"Couldn't find provider of type {providerType}");
+}
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
index 3b20cd160..c7a57859e 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
@@ -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
- {
- 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)
+ using (await _tokenLock.LockAsync(cancellationToken).ConfigureAwait(false))
{
- 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;
diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index bada4249a..c19d8195c 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -6,32 +6,25 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
-using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
-using Jellyfin.Extensions;
using Jellyfin.LiveTv.Configuration;
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;
using Microsoft.Extensions.Logging;
namespace Jellyfin.LiveTv
@@ -41,56 +34,46 @@ 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 IRecordingsManager _recordingsManager;
private readonly LiveTvDtoService _tvDtoService;
- private readonly ITunerHostManager _tunerHostManager;
-
- private ILiveTvService[] _services = Array.Empty<ILiveTvService>();
- private IListingsProvider[] _listingProviders = Array.Empty<IListingsProvider>();
+ private readonly ILiveTvService[] _services;
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,
+ IRecordingsManager recordingsManager,
LiveTvDtoService liveTvDtoService,
- ITunerHostManager tunerHostManager)
+ IEnumerable<ILiveTvService> services)
{
_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;
+ _recordingsManager = recordingsManager;
+ _services = services.ToArray();
+
+ var defaultService = _services.OfType<DefaultLiveTvService>().First();
+ defaultService.TimerCreated += OnEmbyTvTimerCreated;
+ defaultService.TimerCancelled += OnEmbyTvTimerCancelled;
}
public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled;
@@ -107,30 +90,6 @@ namespace Jellyfin.LiveTv
/// <value>The services.</value>
public IReadOnlyList<ILiveTvService> Services => _services;
- public IReadOnlyList<IListingsProvider> ListingProviders => _listingProviders;
-
- public string GetEmbyTvActiveRecordingPath(string id)
- {
- 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 +110,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);
@@ -192,73 +151,6 @@ namespace Jellyfin.LiveTv
return _libraryManager.GetItemsResult(internalQuery);
}
- public async Task<Tuple<MediaSourceInfo, ILiveStream>> GetChannelStream(string id, string mediaSourceId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
- {
- if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
- {
- mediaSourceId = null;
- }
-
- var channel = (LiveTvChannel)_libraryManager.GetItemById(id);
-
- bool isVideo = channel.ChannelType == ChannelType.TV;
- var service = GetService(channel);
- _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId);
-
- MediaSourceInfo info;
-#pragma warning disable CA1859 // TODO: Analyzer bug?
- ILiveStream liveStream;
-#pragma warning restore CA1859
- if (service is ISupportsDirectStreamProvider supportsManagedStream)
- {
- liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
- info = liveStream.MediaSource;
- }
- else
- {
- info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false);
- var openedId = info.Id;
- Func<Task> closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None);
-
- liveStream = new ExclusiveLiveStream(info, closeFn);
-
- var startTime = DateTime.UtcNow;
- await liveStream.Open(cancellationToken).ConfigureAwait(false);
- var endTime = DateTime.UtcNow;
- _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds);
- }
-
- info.RequiresClosing = true;
-
- var idPrefix = service.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_";
-
- info.LiveStreamId = idPrefix + info.Id;
-
- Normalize(info, service, isVideo);
-
- return new Tuple<MediaSourceInfo, ILiveStream>(info, liveStream);
- }
-
- public async Task<IEnumerable<MediaSourceInfo>> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken)
- {
- var baseItem = (LiveTvChannel)item;
- var service = GetService(baseItem);
-
- var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false);
-
- if (sources.Count == 0)
- {
- throw new NotImplementedException();
- }
-
- foreach (var source in sources)
- {
- Normalize(source, service, baseItem.ChannelType == ChannelType.TV);
- }
-
- return sources;
- }
-
private ILiveTvService GetService(LiveTvChannel item)
{
var name = item.ServiceName;
@@ -280,476 +172,6 @@ namespace Jellyfin.LiveTv
"No service with the name '{0}' can be found.",
name));
- private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo)
- {
- // Not all of the plugins are setting this
- mediaSource.IsInfiniteStream = true;
-
- if (mediaSource.MediaStreams.Count == 0)
- {
- if (isVideo)
- {
- mediaSource.MediaStreams = new MediaStream[]
- {
- new MediaStream
- {
- Type = MediaStreamType.Video,
- // Set the index to -1 because we don't know the exact index of the video stream within the container
- Index = -1,
-
- // Set to true if unknown to enable deinterlacing
- IsInterlaced = true
- },
- new MediaStream
- {
- Type = MediaStreamType.Audio,
- // Set the index to -1 because we don't know the exact index of the audio stream within the container
- Index = -1
- }
- };
- }
- else
- {
- mediaSource.MediaStreams = new MediaStream[]
- {
- new MediaStream
- {
- Type = MediaStreamType.Audio,
- // Set the index to -1 because we don't know the exact index of the audio stream within the container
- Index = -1
- }
- };
- }
- }
-
- // Clean some bad data coming from providers
- foreach (var stream in mediaSource.MediaStreams)
- {
- if (stream.BitRate.HasValue && stream.BitRate <= 0)
- {
- stream.BitRate = null;
- }
-
- if (stream.Channels.HasValue && stream.Channels <= 0)
- {
- stream.Channels = null;
- }
-
- if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0)
- {
- stream.AverageFrameRate = null;
- }
-
- if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0)
- {
- stream.RealFrameRate = null;
- }
-
- if (stream.Width.HasValue && stream.Width <= 0)
- {
- stream.Width = null;
- }
-
- if (stream.Height.HasValue && stream.Height <= 0)
- {
- stream.Height = null;
- }
-
- if (stream.SampleRate.HasValue && stream.SampleRate <= 0)
- {
- stream.SampleRate = null;
- }
-
- if (stream.Level.HasValue && stream.Level <= 0)
- {
- stream.Level = null;
- }
- }
-
- var indexes = mediaSource.MediaStreams.Select(i => i.Index).Distinct().ToList();
-
- // If there are duplicate stream indexes, set them all to unknown
- if (indexes.Count != mediaSource.MediaStreams.Count)
- {
- foreach (var stream in mediaSource.MediaStreams)
- {
- stream.Index = -1;
- }
- }
-
- // Set the total bitrate if not already supplied
- mediaSource.InferTotalBitrate();
-
- if (service is not EmbyTV.EmbyTV)
- {
- // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says
- // mediaSource.SupportsDirectPlay = false;
- // mediaSource.SupportsDirectStream = false;
- mediaSource.SupportsTranscoding = true;
- foreach (var stream in mediaSource.MediaStreams)
- {
- if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize))
- {
- stream.NalLengthSize = "0";
- }
-
- if (stream.Type == MediaStreamType.Video)
- {
- stream.IsInterlaced = true;
- }
- }
- }
- }
-
- 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 +423,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)
@@ -1439,18 +574,13 @@ namespace Jellyfin.LiveTv
return AddRecordingInfo(programTuples, CancellationToken.None);
}
- public ActiveRecordingInfo GetActiveRecordingInfo(string path)
- {
- return EmbyTV.EmbyTV.Current.GetActiveRecordingInfo(path);
- }
-
public void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, ActiveRecordingInfo activeRecordingInfo, User user = null)
{
- var service = EmbyTV.EmbyTV.Current;
-
var info = activeRecordingInfo.Timer;
- var channel = string.IsNullOrWhiteSpace(info.ChannelId) ? null : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(service.Name, info.ChannelId));
+ var channel = string.IsNullOrWhiteSpace(info.ChannelId)
+ ? null
+ : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(DefaultLiveTvService.ServiceName, info.ChannelId));
dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId)
? null
@@ -1505,7 +635,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);
@@ -1686,7 +816,7 @@ namespace Jellyfin.LiveTv
await service.CancelTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false);
- if (service is not EmbyTV.EmbyTV)
+ if (service is not DefaultLiveTvService)
{
TimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo>(new TimerEventInfo(id)));
}
@@ -1819,12 +949,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;
@@ -2001,7 +1125,7 @@ namespace Jellyfin.LiveTv
_logger.LogInformation("New recording scheduled");
- if (service is not EmbyTV.EmbyTV)
+ if (service is not DefaultLiveTvService)
{
TimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo>(
new TimerEventInfo(newTimerId)
@@ -2057,18 +1181,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();
@@ -2147,178 +1259,13 @@ namespace Jellyfin.LiveTv
return _libraryManager.GetNamedView(name, CollectionType.livetv, name);
}
- public async Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings)
- {
- // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider
- // ServerConfiguration.SaveConfiguration crashes during xml serialization for AddListingProvider
- info = JsonSerializer.Deserialize<ListingsProviderInfo>(JsonSerializer.SerializeToUtf8Bytes(info));
-
- var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
-
- if (provider is null)
- {
- throw new ResourceNotFoundException(
- string.Format(
- CultureInfo.InvariantCulture,
- "Couldn't find provider of type: '{0}'",
- info.Type));
- }
-
- await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false);
-
- var config = _config.GetLiveTvConfiguration();
-
- var list = config.ListingProviders.ToList();
- int index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
-
- if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
- {
- info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
- list.Add(info);
- config.ListingProviders = list.ToArray();
- }
- else
- {
- config.ListingProviders[index] = info;
- }
-
- _config.SaveConfiguration("livetv", config);
-
- _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
-
- return info;
- }
-
- public void DeleteListingsProvider(string id)
- {
- var config = _config.GetLiveTvConfiguration();
-
- config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
-
- _config.SaveConfiguration("livetv", config);
- _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
- }
-
- public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber)
- {
- var config = _config.GetLiveTvConfiguration();
-
- var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase));
- listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings.Where(i => !string.Equals(i.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray();
-
- if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase))
- {
- var list = listingsProviderInfo.ChannelMappings.ToList();
- list.Add(new NameValuePair
- {
- Name = tunerChannelNumber,
- Value = providerChannelNumber
- });
- listingsProviderInfo.ChannelMappings = list.ToArray();
- }
-
- _config.SaveConfiguration("livetv", config);
-
- var tunerChannels = await GetChannelsForListingsProvider(providerId, CancellationToken.None)
- .ConfigureAwait(false);
-
- var providerChannels = await GetChannelsFromListingsProviderData(providerId, CancellationToken.None)
- .ConfigureAwait(false);
-
- var mappings = listingsProviderInfo.ChannelMappings;
-
- var tunerChannelMappings =
- tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList();
-
- _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
-
- return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase));
- }
-
- public TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List<ChannelInfo> providerChannels)
- {
- var result = new TunerChannelMapping
- {
- Name = tunerChannel.Name,
- Id = tunerChannel.Id
- };
-
- if (!string.IsNullOrWhiteSpace(tunerChannel.Number))
- {
- result.Name = tunerChannel.Number + " " + result.Name;
- }
-
- var providerChannel = EmbyTV.EmbyTV.Current.GetEpgChannelFromTunerChannel(mappings, tunerChannel, providerChannels);
-
- if (providerChannel is not null)
- {
- result.ProviderChannelName = providerChannel.Name;
- result.ProviderChannelId = providerChannel.Id;
- }
-
- return result;
- }
-
- public Task<List<NameIdPair>> GetLineups(string providerType, string providerId, string country, string location)
- {
- var config = _config.GetLiveTvConfiguration();
-
- if (string.IsNullOrWhiteSpace(providerId))
- {
- var provider = _listingProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase));
-
- if (provider is null)
- {
- throw new ResourceNotFoundException();
- }
-
- return provider.GetLineups(null, country, location);
- }
- else
- {
- var info = config.ListingProviders.FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase));
-
- var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
-
- if (provider is null)
- {
- throw new ResourceNotFoundException();
- }
-
- return provider.GetLineups(info, country, location);
- }
- }
-
- public Task<List<ChannelInfo>> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken)
- {
- var info = _config.GetLiveTvConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
- return EmbyTV.EmbyTV.Current.GetChannelsForListingsProvider(info, cancellationToken);
- }
-
- public Task<List<ChannelInfo>> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken)
- {
- var info = _config.GetLiveTvConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
- var provider = _listingProviders.First(i => string.Equals(i.Type, info.Type, StringComparison.OrdinalIgnoreCase));
- 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);
private async Task<BaseItem[]> GetRecordingFoldersAsync(User user, bool refreshChannels)
{
- var folders = EmbyTV.EmbyTV.Current.GetRecordingFolders()
+ var folders = _recordingsManager.GetRecordingFolders()
.SelectMany(i => i.Locations)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(i => _libraryManager.FindByPath(i, true))
diff --git a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
index ce9361089..40ac5ce0f 100644
--- a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
+++ b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
@@ -8,11 +8,15 @@ using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.LiveTv.IO;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
@@ -23,24 +27,34 @@ namespace Jellyfin.LiveTv
// Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message.
private const char StreamIdDelimiter = '_';
- private readonly ILiveTvManager _liveTvManager;
private readonly ILogger<LiveTvMediaSourceProvider> _logger;
- private readonly IMediaSourceManager _mediaSourceManager;
private readonly IServerApplicationHost _appHost;
+ private readonly IRecordingsManager _recordingsManager;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILiveTvService[] _services;
- public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, ILogger<LiveTvMediaSourceProvider> logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost)
+ public LiveTvMediaSourceProvider(
+ ILogger<LiveTvMediaSourceProvider> logger,
+ IServerApplicationHost appHost,
+ IRecordingsManager recordingsManager,
+ IMediaSourceManager mediaSourceManager,
+ ILibraryManager libraryManager,
+ IEnumerable<ILiveTvService> services)
{
- _liveTvManager = liveTvManager;
_logger = logger;
- _mediaSourceManager = mediaSourceManager;
_appHost = appHost;
+ _recordingsManager = recordingsManager;
+ _mediaSourceManager = mediaSourceManager;
+ _libraryManager = libraryManager;
+ _services = services.ToArray();
}
public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
{
if (item.SourceType == SourceType.LiveTV)
{
- var activeRecordingInfo = _liveTvManager.GetActiveRecordingInfo(item.Path);
+ var activeRecordingInfo = _recordingsManager.GetActiveRecordingInfo(item.Path);
if (string.IsNullOrEmpty(item.Path) || activeRecordingInfo is not null)
{
@@ -66,7 +80,7 @@ namespace Jellyfin.LiveTv
}
else
{
- sources = await _liveTvManager.GetChannelMediaSources(item, cancellationToken)
+ sources = await GetChannelMediaSources(item, cancellationToken)
.ConfigureAwait(false);
}
}
@@ -119,10 +133,200 @@ namespace Jellyfin.LiveTv
var keys = openToken.Split(StreamIdDelimiter, 3);
var mediaSourceId = keys.Length >= 3 ? keys[2] : null;
- var info = await _liveTvManager.GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
+ var info = await GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
var liveStream = info.Item2;
return liveStream;
}
+
+ private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo)
+ {
+ // Not all of the plugins are setting this
+ mediaSource.IsInfiniteStream = true;
+
+ if (mediaSource.MediaStreams.Count == 0)
+ {
+ if (isVideo)
+ {
+ mediaSource.MediaStreams = new[]
+ {
+ new MediaStream
+ {
+ Type = MediaStreamType.Video,
+ // Set the index to -1 because we don't know the exact index of the video stream within the container
+ Index = -1,
+ // Set to true if unknown to enable deinterlacing
+ IsInterlaced = true
+ },
+ new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ // Set the index to -1 because we don't know the exact index of the audio stream within the container
+ Index = -1
+ }
+ };
+ }
+ else
+ {
+ mediaSource.MediaStreams = new[]
+ {
+ new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ // Set the index to -1 because we don't know the exact index of the audio stream within the container
+ Index = -1
+ }
+ };
+ }
+ }
+
+ // Clean some bad data coming from providers
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ if (stream.BitRate is <= 0)
+ {
+ stream.BitRate = null;
+ }
+
+ if (stream.Channels is <= 0)
+ {
+ stream.Channels = null;
+ }
+
+ if (stream.AverageFrameRate is <= 0)
+ {
+ stream.AverageFrameRate = null;
+ }
+
+ if (stream.RealFrameRate is <= 0)
+ {
+ stream.RealFrameRate = null;
+ }
+
+ if (stream.Width is <= 0)
+ {
+ stream.Width = null;
+ }
+
+ if (stream.Height is <= 0)
+ {
+ stream.Height = null;
+ }
+
+ if (stream.SampleRate is <= 0)
+ {
+ stream.SampleRate = null;
+ }
+
+ if (stream.Level is <= 0)
+ {
+ stream.Level = null;
+ }
+ }
+
+ var indexCount = mediaSource.MediaStreams.Select(i => i.Index).Distinct().Count();
+
+ // If there are duplicate stream indexes, set them all to unknown
+ if (indexCount != mediaSource.MediaStreams.Count)
+ {
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ stream.Index = -1;
+ }
+ }
+
+ // Set the total bitrate if not already supplied
+ mediaSource.InferTotalBitrate();
+
+ if (service is not DefaultLiveTvService)
+ {
+ mediaSource.SupportsTranscoding = true;
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize))
+ {
+ stream.NalLengthSize = "0";
+ }
+
+ if (stream.Type == MediaStreamType.Video)
+ {
+ stream.IsInterlaced = true;
+ }
+ }
+ }
+ }
+
+ private async Task<Tuple<MediaSourceInfo, ILiveStream>> GetChannelStream(
+ string id,
+ string mediaSourceId,
+ List<ILiveStream> currentLiveStreams,
+ CancellationToken cancellationToken)
+ {
+ if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
+ {
+ mediaSourceId = null;
+ }
+
+ var channel = (LiveTvChannel)_libraryManager.GetItemById(id);
+
+ bool isVideo = channel.ChannelType == ChannelType.TV;
+ var service = GetService(channel.ServiceName);
+ _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId);
+
+ MediaSourceInfo info;
+#pragma warning disable CA1859 // TODO: Analyzer bug?
+ ILiveStream liveStream;
+#pragma warning restore CA1859
+ if (service is ISupportsDirectStreamProvider supportsManagedStream)
+ {
+ liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
+ info = liveStream.MediaSource;
+ }
+ else
+ {
+ info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false);
+ var openedId = info.Id;
+ Func<Task> closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None);
+
+ liveStream = new ExclusiveLiveStream(info, closeFn);
+
+ var startTime = DateTime.UtcNow;
+ await liveStream.Open(cancellationToken).ConfigureAwait(false);
+ var endTime = DateTime.UtcNow;
+ _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds);
+ }
+
+ info.RequiresClosing = true;
+
+ var idPrefix = service.GetType().FullName!.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_";
+
+ info.LiveStreamId = idPrefix + info.Id;
+
+ Normalize(info, service, isVideo);
+
+ return new Tuple<MediaSourceInfo, ILiveStream>(info, liveStream);
+ }
+
+ private async Task<List<MediaSourceInfo>> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken)
+ {
+ var baseItem = (LiveTvChannel)item;
+ var service = GetService(baseItem.ServiceName);
+
+ var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false);
+ if (sources.Count == 0)
+ {
+ throw new NotImplementedException();
+ }
+
+ foreach (var source in sources)
+ {
+ Normalize(source, service, baseItem.ChannelType == ChannelType.TV);
+ }
+
+ return sources;
+ }
+
+ private ILiveTvService GetService(string name)
+ => _services.First(service => string.Equals(service.Name, name, StringComparison.OrdinalIgnoreCase));
}
}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs
index 6bda231b2..2b7564045 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs
@@ -1,19 +1,12 @@
-#pragma warning disable CS1591
-
using System;
using System.Globalization;
using System.Text;
using MediaBrowser.Controller.LiveTv;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.Recordings
{
internal static class RecordingHelper
{
- public static DateTime GetStartTime(TimerInfo timer)
- {
- return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds);
- }
-
public static string GetRecordingName(TimerInfo info)
{
var name = info.Name;
diff --git a/src/Jellyfin.LiveTv/RecordingNotifier.cs b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs
index 2923948eb..e63afa626 100644
--- a/src/Jellyfin.LiveTv/RecordingNotifier.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs
@@ -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
+namespace Jellyfin.LiveTv.Recordings
{
- 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;
- private async void OnLiveTvManagerTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e)
- {
- await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false);
+ return Task.CompletedTask;
}
- private async void OnLiveTvManagerSeriesTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e)
- {
- await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false);
- }
+ private async void OnLiveTvManagerSeriesTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e)
+ => await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false);
- private async void OnLiveTvManagerTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e)
- {
- await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false);
- }
+ private async void OnLiveTvManagerTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e)
+ => await SendMessage(SessionMessageType.TimerCreated, 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;
- }
}
}
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs
new file mode 100644
index 000000000..f4daa0975
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs
@@ -0,0 +1,37 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.LiveTv.Timers;
+using MediaBrowser.Controller.LiveTv;
+using Microsoft.Extensions.Hosting;
+
+namespace Jellyfin.LiveTv.Recordings;
+
+/// <summary>
+/// <see cref="IHostedService"/> responsible for Live TV recordings.
+/// </summary>
+public sealed class RecordingsHost : IHostedService
+{
+ private readonly IRecordingsManager _recordingsManager;
+ private readonly TimerManager _timerManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RecordingsHost"/> class.
+ /// </summary>
+ /// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
+ /// <param name="timerManager">The <see cref="TimerManager"/>.</param>
+ public RecordingsHost(IRecordingsManager recordingsManager, TimerManager timerManager)
+ {
+ _recordingsManager = recordingsManager;
+ _timerManager = timerManager;
+ }
+
+ /// <inheritdoc />
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _timerManager.RestartTimers();
+ return _recordingsManager.CreateRecordingFolders();
+ }
+
+ /// <inheritdoc />
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
new file mode 100644
index 000000000..92605a1eb
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
@@ -0,0 +1,837 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using AsyncKeyedLock;
+using Jellyfin.Data.Enums;
+using Jellyfin.LiveTv.Configuration;
+using Jellyfin.LiveTv.IO;
+using Jellyfin.LiveTv.Timers;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Providers;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.Recordings;
+
+/// <inheritdoc cref="IRecordingsManager" />
+public sealed class RecordingsManager : IRecordingsManager, IDisposable
+{
+ private readonly ILogger<RecordingsManager> _logger;
+ private readonly IServerConfigurationManager _config;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILibraryMonitor _libraryMonitor;
+ private readonly IProviderManager _providerManager;
+ private readonly IMediaEncoder _mediaEncoder;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IStreamHelper _streamHelper;
+ private readonly TimerManager _timerManager;
+ private readonly SeriesTimerManager _seriesTimerManager;
+ private readonly RecordingsMetadataManager _recordingsMetadataManager;
+
+ private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings = new(StringComparer.OrdinalIgnoreCase);
+ private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new();
+ private bool _disposed;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RecordingsManager"/> class.
+ /// </summary>
+ /// <param name="logger">The <see cref="ILogger"/>.</param>
+ /// <param name="config">The <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
+ /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
+ /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
+ /// <param name="libraryMonitor">The <see cref="ILibraryMonitor"/>.</param>
+ /// <param name="providerManager">The <see cref="IProviderManager"/>.</param>
+ /// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
+ /// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
+ /// <param name="streamHelper">The <see cref="IStreamHelper"/>.</param>
+ /// <param name="timerManager">The <see cref="TimerManager"/>.</param>
+ /// <param name="seriesTimerManager">The <see cref="SeriesTimerManager"/>.</param>
+ /// <param name="recordingsMetadataManager">The <see cref="RecordingsMetadataManager"/>.</param>
+ public RecordingsManager(
+ ILogger<RecordingsManager> logger,
+ IServerConfigurationManager config,
+ IHttpClientFactory httpClientFactory,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ ILibraryMonitor libraryMonitor,
+ IProviderManager providerManager,
+ IMediaEncoder mediaEncoder,
+ IMediaSourceManager mediaSourceManager,
+ IStreamHelper streamHelper,
+ TimerManager timerManager,
+ SeriesTimerManager seriesTimerManager,
+ RecordingsMetadataManager recordingsMetadataManager)
+ {
+ _logger = logger;
+ _config = config;
+ _httpClientFactory = httpClientFactory;
+ _fileSystem = fileSystem;
+ _libraryManager = libraryManager;
+ _libraryMonitor = libraryMonitor;
+ _providerManager = providerManager;
+ _mediaEncoder = mediaEncoder;
+ _mediaSourceManager = mediaSourceManager;
+ _streamHelper = streamHelper;
+ _timerManager = timerManager;
+ _seriesTimerManager = seriesTimerManager;
+ _recordingsMetadataManager = recordingsMetadataManager;
+
+ _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
+ }
+
+ private string DefaultRecordingPath
+ {
+ get
+ {
+ var path = _config.GetLiveTvConfiguration().RecordingPath;
+
+ return string.IsNullOrWhiteSpace(path)
+ ? Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv", "recordings")
+ : path;
+ }
+ }
+
+ /// <inheritdoc />
+ public string? GetActiveRecordingPath(string id)
+ => _activeRecordings.GetValueOrDefault(id)?.Path;
+
+ /// <inheritdoc />
+ public ActiveRecordingInfo? GetActiveRecordingInfo(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty)
+ {
+ return null;
+ }
+
+ foreach (var (_, recordingInfo) in _activeRecordings)
+ {
+ if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal)
+ && !recordingInfo.CancellationTokenSource.IsCancellationRequested)
+ {
+ return recordingInfo.Timer.Status == RecordingStatus.InProgress ? recordingInfo : null;
+ }
+ }
+
+ return null;
+ }
+
+ /// <inheritdoc />
+ public IEnumerable<VirtualFolderInfo> GetRecordingFolders()
+ {
+ if (Directory.Exists(DefaultRecordingPath))
+ {
+ yield return new VirtualFolderInfo
+ {
+ Locations = [DefaultRecordingPath],
+ Name = "Recordings"
+ };
+ }
+
+ var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath;
+ if (!string.IsNullOrWhiteSpace(customPath)
+ && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase)
+ && Directory.Exists(customPath))
+ {
+ yield return new VirtualFolderInfo
+ {
+ Locations = [customPath],
+ Name = "Recorded Movies",
+ CollectionType = CollectionTypeOptions.Movies
+ };
+ }
+
+ customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath;
+ if (!string.IsNullOrWhiteSpace(customPath)
+ && !string.Equals(customPath, DefaultRecordingPath, StringComparison.OrdinalIgnoreCase)
+ && Directory.Exists(customPath))
+ {
+ yield return new VirtualFolderInfo
+ {
+ Locations = [customPath],
+ Name = "Recorded Shows",
+ CollectionType = CollectionTypeOptions.TvShows
+ };
+ }
+ }
+
+ /// <inheritdoc />
+ public async Task CreateRecordingFolders()
+ {
+ try
+ {
+ var recordingFolders = GetRecordingFolders().ToArray();
+ var virtualFolders = _libraryManager.GetVirtualFolders();
+
+ var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList();
+
+ var pathsAdded = new List<string>();
+
+ foreach (var recordingFolder in recordingFolders)
+ {
+ var pathsToCreate = recordingFolder.Locations
+ .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i)))
+ .ToList();
+
+ if (pathsToCreate.Count == 0)
+ {
+ continue;
+ }
+
+ var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray();
+ var libraryOptions = new LibraryOptions
+ {
+ PathInfos = mediaPathInfos
+ };
+
+ try
+ {
+ await _libraryManager
+ .AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error creating virtual folder");
+ }
+
+ pathsAdded.AddRange(pathsToCreate);
+ }
+
+ var config = _config.GetLiveTvConfiguration();
+
+ var pathsToRemove = config.MediaLocationsCreated
+ .Except(recordingFolders.SelectMany(i => i.Locations))
+ .ToList();
+
+ if (pathsAdded.Count > 0 || pathsToRemove.Count > 0)
+ {
+ pathsAdded.InsertRange(0, config.MediaLocationsCreated);
+ config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ _config.SaveConfiguration("livetv", config);
+ }
+
+ foreach (var path in pathsToRemove)
+ {
+ await RemovePathFromLibraryAsync(path).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error creating recording folders");
+ }
+ }
+
+ private async Task RemovePathFromLibraryAsync(string path)
+ {
+ _logger.LogDebug("Removing path from library: {0}", path);
+
+ var requiresRefresh = false;
+ var virtualFolders = _libraryManager.GetVirtualFolders();
+
+ foreach (var virtualFolder in virtualFolders)
+ {
+ if (!virtualFolder.Locations.Contains(path, StringComparer.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (virtualFolder.Locations.Length == 1)
+ {
+ try
+ {
+ await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error removing virtual folder");
+ }
+ }
+ else
+ {
+ try
+ {
+ _libraryManager.RemoveMediaPath(virtualFolder.Name, path);
+ requiresRefresh = true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error removing media path");
+ }
+ }
+ }
+
+ if (requiresRefresh)
+ {
+ await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+
+ /// <inheritdoc />
+ public void CancelRecording(string timerId, TimerInfo? timer)
+ {
+ if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo))
+ {
+ activeRecordingInfo.Timer = timer;
+ activeRecordingInfo.CancellationTokenSource.Cancel();
+ }
+ }
+
+ /// <inheritdoc />
+ public async Task RecordStream(ActiveRecordingInfo recordingInfo, BaseItem channel, DateTime recordingEndDate)
+ {
+ ArgumentNullException.ThrowIfNull(recordingInfo);
+ ArgumentNullException.ThrowIfNull(channel);
+
+ var timer = recordingInfo.Timer;
+ var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false);
+ var recordingPath = GetRecordingPath(timer, remoteMetadata, out var seriesPath);
+
+ string? liveStreamId = null;
+ RecordingStatus recordingStatus;
+ try
+ {
+ var allMediaSources = await _mediaSourceManager
+ .GetPlaybackMediaSources(channel, null, true, false, CancellationToken.None).ConfigureAwait(false);
+
+ var mediaStreamInfo = allMediaSources[0];
+ IDirectStreamProvider? directStreamProvider = null;
+ if (mediaStreamInfo.RequiresOpening)
+ {
+ var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal(
+ new LiveStreamRequest
+ {
+ ItemId = channel.Id,
+ OpenToken = mediaStreamInfo.OpenToken
+ },
+ CancellationToken.None).ConfigureAwait(false);
+
+ mediaStreamInfo = liveStreamResponse.Item1.MediaSource;
+ liveStreamId = mediaStreamInfo.LiveStreamId;
+ directStreamProvider = liveStreamResponse.Item2;
+ }
+
+ using var recorder = GetRecorder(mediaStreamInfo);
+
+ recordingPath = recorder.GetOutputPath(mediaStreamInfo, recordingPath);
+ recordingPath = EnsureFileUnique(recordingPath, timer.Id);
+
+ _libraryMonitor.ReportFileSystemChangeBeginning(recordingPath);
+
+ var duration = recordingEndDate - DateTime.UtcNow;
+
+ _logger.LogInformation("Beginning recording. Will record for {Duration} minutes.", duration.TotalMinutes);
+ _logger.LogInformation("Writing file to: {Path}", recordingPath);
+
+ async void OnStarted()
+ {
+ recordingInfo.Path = recordingPath;
+ _activeRecordings.TryAdd(timer.Id, recordingInfo);
+
+ timer.Status = RecordingStatus.InProgress;
+ _timerManager.AddOrUpdate(timer, false);
+
+ await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordingPath, seriesPath).ConfigureAwait(false);
+ await CreateRecordingFolders().ConfigureAwait(false);
+
+ TriggerRefresh(recordingPath);
+ await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false);
+ }
+
+ await recorder.Record(
+ directStreamProvider,
+ mediaStreamInfo,
+ recordingPath,
+ duration,
+ OnStarted,
+ recordingInfo.CancellationTokenSource.Token).ConfigureAwait(false);
+
+ recordingStatus = RecordingStatus.Completed;
+ _logger.LogInformation("Recording completed: {RecordPath}", recordingPath);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("Recording stopped: {RecordPath}", recordingPath);
+ recordingStatus = RecordingStatus.Completed;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error recording to {RecordPath}", recordingPath);
+ recordingStatus = RecordingStatus.Error;
+ }
+
+ if (!string.IsNullOrWhiteSpace(liveStreamId))
+ {
+ try
+ {
+ await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error closing live stream");
+ }
+ }
+
+ DeleteFileIfEmpty(recordingPath);
+ TriggerRefresh(recordingPath);
+ _libraryMonitor.ReportFileSystemChangeComplete(recordingPath, false);
+ _activeRecordings.TryRemove(timer.Id, out _);
+
+ if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10)
+ {
+ const int RetryIntervalSeconds = 60;
+ _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds);
+
+ timer.Status = RecordingStatus.New;
+ timer.PrePaddingSeconds = 0;
+ timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds);
+ timer.RetryCount++;
+ _timerManager.AddOrUpdate(timer);
+ }
+ else if (File.Exists(recordingPath))
+ {
+ timer.RecordingPath = recordingPath;
+ timer.Status = RecordingStatus.Completed;
+ _timerManager.AddOrUpdate(timer, false);
+ await PostProcessRecording(recordingPath).ConfigureAwait(false);
+ }
+ else
+ {
+ _timerManager.Delete(timer);
+ }
+ }
+
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _recordingDeleteSemaphore.Dispose();
+
+ foreach (var pair in _activeRecordings.ToList())
+ {
+ pair.Value.CancellationTokenSource.Cancel();
+ }
+
+ _disposed = true;
+ }
+
+ private async void OnNamedConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs e)
+ {
+ if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase))
+ {
+ await CreateRecordingFolders().ConfigureAwait(false);
+ }
+ }
+
+ private async Task<RemoteSearchResult?> FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken)
+ {
+ if (!timer.IsSeries || timer.SeriesProviderIds.Count == 0)
+ {
+ return null;
+ }
+
+ var query = new RemoteSearchQuery<SeriesInfo>
+ {
+ SearchInfo = new SeriesInfo
+ {
+ ProviderIds = timer.SeriesProviderIds,
+ Name = timer.Name,
+ MetadataCountryCode = _config.Configuration.MetadataCountryCode,
+ MetadataLanguage = _config.Configuration.PreferredMetadataLanguage
+ }
+ };
+
+ var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, cancellationToken).ConfigureAwait(false);
+
+ return results.FirstOrDefault();
+ }
+
+ private string GetRecordingPath(TimerInfo timer, RemoteSearchResult? metadata, out string? seriesPath)
+ {
+ var recordingPath = DefaultRecordingPath;
+ var config = _config.GetLiveTvConfiguration();
+ seriesPath = null;
+
+ if (timer.IsProgramSeries)
+ {
+ var customRecordingPath = config.SeriesRecordingPath;
+ var allowSubfolder = true;
+ if (!string.IsNullOrWhiteSpace(customRecordingPath))
+ {
+ allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase);
+ recordingPath = customRecordingPath;
+ }
+
+ if (allowSubfolder && config.EnableRecordingSubfolders)
+ {
+ recordingPath = Path.Combine(recordingPath, "Series");
+ }
+
+ // trim trailing period from the folder name
+ var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim();
+
+ if (metadata is not null && metadata.ProductionYear.HasValue)
+ {
+ folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
+ }
+
+ // Can't use the year here in the folder name because it is the year of the episode, not the series.
+ recordingPath = Path.Combine(recordingPath, folderName);
+
+ seriesPath = recordingPath;
+
+ if (timer.SeasonNumber.HasValue)
+ {
+ folderName = string.Format(
+ CultureInfo.InvariantCulture,
+ "Season {0}",
+ timer.SeasonNumber.Value);
+ recordingPath = Path.Combine(recordingPath, folderName);
+ }
+ }
+ else if (timer.IsMovie)
+ {
+ var customRecordingPath = config.MovieRecordingPath;
+ var allowSubfolder = true;
+ if (!string.IsNullOrWhiteSpace(customRecordingPath))
+ {
+ allowSubfolder = string.Equals(customRecordingPath, recordingPath, StringComparison.OrdinalIgnoreCase);
+ recordingPath = customRecordingPath;
+ }
+
+ if (allowSubfolder && config.EnableRecordingSubfolders)
+ {
+ recordingPath = Path.Combine(recordingPath, "Movies");
+ }
+
+ var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
+ if (timer.ProductionYear.HasValue)
+ {
+ folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
+ }
+
+ // trim trailing period from the folder name
+ folderName = folderName.TrimEnd('.').Trim();
+
+ recordingPath = Path.Combine(recordingPath, folderName);
+ }
+ else if (timer.IsKids)
+ {
+ if (config.EnableRecordingSubfolders)
+ {
+ recordingPath = Path.Combine(recordingPath, "Kids");
+ }
+
+ var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
+ if (timer.ProductionYear.HasValue)
+ {
+ folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
+ }
+
+ // trim trailing period from the folder name
+ folderName = folderName.TrimEnd('.').Trim();
+
+ recordingPath = Path.Combine(recordingPath, folderName);
+ }
+ else if (timer.IsSports)
+ {
+ if (config.EnableRecordingSubfolders)
+ {
+ recordingPath = Path.Combine(recordingPath, "Sports");
+ }
+
+ recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim());
+ }
+ else
+ {
+ if (config.EnableRecordingSubfolders)
+ {
+ recordingPath = Path.Combine(recordingPath, "Other");
+ }
+
+ recordingPath = Path.Combine(recordingPath, _fileSystem.GetValidFilename(timer.Name).Trim());
+ }
+
+ var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts";
+
+ return Path.Combine(recordingPath, recordingFileName);
+ }
+
+ private void DeleteFileIfEmpty(string path)
+ {
+ var file = _fileSystem.GetFileInfo(path);
+
+ if (file.Exists && file.Length == 0)
+ {
+ try
+ {
+ _fileSystem.DeleteFile(path);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path);
+ }
+ }
+ }
+
+ private void TriggerRefresh(string path)
+ {
+ _logger.LogInformation("Triggering refresh on {Path}", path);
+
+ var item = GetAffectedBaseItem(Path.GetDirectoryName(path));
+ if (item is null)
+ {
+ return;
+ }
+
+ _logger.LogInformation("Refreshing recording parent {Path}", item.Path);
+ _providerManager.QueueRefresh(
+ item.Id,
+ new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+ {
+ RefreshPaths =
+ [
+ path,
+ Path.GetDirectoryName(path),
+ Path.GetDirectoryName(Path.GetDirectoryName(path))
+ ]
+ },
+ RefreshPriority.High);
+ }
+
+ private BaseItem? GetAffectedBaseItem(string? path)
+ {
+ BaseItem? item = null;
+ var parentPath = Path.GetDirectoryName(path);
+ while (item is null && !string.IsNullOrEmpty(path))
+ {
+ item = _libraryManager.FindByPath(path, null);
+ path = Path.GetDirectoryName(path);
+ }
+
+ if (item is not null
+ && item.GetType() == typeof(Folder)
+ && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase))
+ {
+ var parentItem = item.GetParent();
+ if (parentItem is not null && parentItem is not AggregateFolder)
+ {
+ item = parentItem;
+ }
+ }
+
+ return item;
+ }
+
+ private async Task EnforceKeepUpTo(TimerInfo timer, string? seriesPath)
+ {
+ if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)
+ || string.IsNullOrWhiteSpace(seriesPath))
+ {
+ return;
+ }
+
+ var seriesTimerId = timer.SeriesTimerId;
+ var seriesTimer = _seriesTimerManager.GetAll()
+ .FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase));
+
+ if (seriesTimer is null || seriesTimer.KeepUpTo <= 0)
+ {
+ return;
+ }
+
+ if (_disposed)
+ {
+ return;
+ }
+
+ using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false))
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ var timersToDelete = _timerManager.GetAll()
+ .Where(timerInfo => timerInfo.Status == RecordingStatus.Completed
+ && !string.IsNullOrWhiteSpace(timerInfo.RecordingPath)
+ && string.Equals(timerInfo.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)
+ && File.Exists(timerInfo.RecordingPath))
+ .OrderByDescending(i => i.EndDate)
+ .Skip(seriesTimer.KeepUpTo - 1)
+ .ToList();
+
+ DeleteLibraryItemsForTimers(timersToDelete);
+
+ if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries)
+ {
+ return;
+ }
+
+ var episodesToDelete = librarySeries.GetItemList(
+ new InternalItemsQuery
+ {
+ OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending)],
+ IsVirtualItem = false,
+ IsFolder = false,
+ Recursive = true,
+ DtoOptions = new DtoOptions(true)
+ })
+ .Where(i => i.IsFileProtocol && File.Exists(i.Path))
+ .Skip(seriesTimer.KeepUpTo - 1);
+
+ foreach (var item in episodesToDelete)
+ {
+ try
+ {
+ _libraryManager.DeleteItem(
+ item,
+ new DeleteOptions
+ {
+ DeleteFileLocation = true
+ },
+ true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error deleting item");
+ }
+ }
+ }
+ }
+
+ private void DeleteLibraryItemsForTimers(List<TimerInfo> timers)
+ {
+ foreach (var timer in timers)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ try
+ {
+ DeleteLibraryItemForTimer(timer);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error deleting recording");
+ }
+ }
+ }
+
+ private void DeleteLibraryItemForTimer(TimerInfo timer)
+ {
+ var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false);
+ if (libraryItem is not null)
+ {
+ _libraryManager.DeleteItem(
+ libraryItem,
+ new DeleteOptions
+ {
+ DeleteFileLocation = true
+ },
+ true);
+ }
+ else if (File.Exists(timer.RecordingPath))
+ {
+ _fileSystem.DeleteFile(timer.RecordingPath);
+ }
+
+ _timerManager.Delete(timer);
+ }
+
+ private string EnsureFileUnique(string path, string timerId)
+ {
+ var parent = Path.GetDirectoryName(path)!;
+ var name = Path.GetFileNameWithoutExtension(path);
+ var extension = Path.GetExtension(path);
+
+ var index = 1;
+ while (File.Exists(path) || _activeRecordings.Any(i
+ => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)))
+ {
+ name += " - " + index.ToString(CultureInfo.InvariantCulture);
+
+ path = Path.ChangeExtension(Path.Combine(parent, name), extension);
+ index++;
+ }
+
+ return path;
+ }
+
+ private IRecorder GetRecorder(MediaSourceInfo mediaSource)
+ {
+ if (mediaSource.RequiresLooping
+ || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase)
+ || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http))
+ {
+ return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config);
+ }
+
+ return new DirectRecorder(_logger, _httpClientFactory, _streamHelper);
+ }
+
+ private async Task PostProcessRecording(string path)
+ {
+ var options = _config.GetLiveTvConfiguration();
+ if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor))
+ {
+ return;
+ }
+
+ try
+ {
+ using var process = new Process();
+ process.StartInfo = new ProcessStartInfo
+ {
+ Arguments = options.RecordingPostProcessorArguments
+ .Replace("{path}", path, StringComparison.OrdinalIgnoreCase),
+ CreateNoWindow = true,
+ ErrorDialog = false,
+ FileName = options.RecordingPostProcessor,
+ WindowStyle = ProcessWindowStyle.Hidden,
+ UseShellExecute = false
+ };
+ process.EnableRaisingEvents = true;
+
+ _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
+
+ process.Start();
+ await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
+
+ _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error running recording post processor");
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
new file mode 100644
index 000000000..b2b82332d
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
@@ -0,0 +1,501 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Xml;
+using Jellyfin.Data.Enums;
+using Jellyfin.Extensions;
+using Jellyfin.LiveTv.Configuration;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.Recordings;
+
+/// <summary>
+/// A service responsible for saving recording metadata.
+/// </summary>
+public class RecordingsMetadataManager
+{
+ private const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss";
+
+ private readonly ILogger<RecordingsMetadataManager> _logger;
+ private readonly IConfigurationManager _config;
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RecordingsMetadataManager"/> class.
+ /// </summary>
+ /// <param name="logger">The <see cref="ILogger"/>.</param>
+ /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
+ /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
+ public RecordingsMetadataManager(
+ ILogger<RecordingsMetadataManager> logger,
+ IConfigurationManager config,
+ ILibraryManager libraryManager)
+ {
+ _logger = logger;
+ _config = config;
+ _libraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Saves the metadata for a provided recording.
+ /// </summary>
+ /// <param name="timer">The recording timer.</param>
+ /// <param name="recordingPath">The recording path.</param>
+ /// <param name="seriesPath">The series path.</param>
+ /// <returns>A task representing the metadata saving.</returns>
+ public async Task SaveRecordingMetadata(TimerInfo timer, string recordingPath, string? seriesPath)
+ {
+ try
+ {
+ var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.LiveTvProgram],
+ Limit = 1,
+ ExternalId = timer.ProgramId,
+ DtoOptions = new DtoOptions(true)
+ }).FirstOrDefault() as LiveTvProgram;
+
+ // dummy this up
+ program ??= new LiveTvProgram
+ {
+ Name = timer.Name,
+ Overview = timer.Overview,
+ Genres = timer.Genres,
+ CommunityRating = timer.CommunityRating,
+ OfficialRating = timer.OfficialRating,
+ ProductionYear = timer.ProductionYear,
+ PremiereDate = timer.OriginalAirDate,
+ IndexNumber = timer.EpisodeNumber,
+ ParentIndexNumber = timer.SeasonNumber
+ };
+
+ if (timer.IsSports)
+ {
+ program.AddGenre("Sports");
+ }
+
+ if (timer.IsKids)
+ {
+ program.AddGenre("Kids");
+ program.AddGenre("Children");
+ }
+
+ if (timer.IsNews)
+ {
+ program.AddGenre("News");
+ }
+
+ var config = _config.GetLiveTvConfiguration();
+
+ if (config.SaveRecordingNFO)
+ {
+ if (timer.IsProgramSeries)
+ {
+ ArgumentNullException.ThrowIfNull(seriesPath);
+
+ await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false);
+ await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
+ }
+ else if (!timer.IsMovie || timer.IsSports || timer.IsNews)
+ {
+ await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false);
+ }
+ else
+ {
+ await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false);
+ }
+ }
+
+ if (config.SaveRecordingImages)
+ {
+ await SaveRecordingImages(recordingPath, program).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error saving nfo");
+ }
+ }
+
+ private static async Task SaveSeriesNfoAsync(TimerInfo timer, string seriesPath)
+ {
+ var nfoPath = Path.Combine(seriesPath, "tvshow.nfo");
+
+ if (File.Exists(nfoPath))
+ {
+ return;
+ }
+
+ var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
+ await using (stream.ConfigureAwait(false))
+ {
+ var settings = new XmlWriterSettings
+ {
+ Indent = true,
+ Encoding = Encoding.UTF8,
+ Async = true
+ };
+
+ var writer = XmlWriter.Create(stream, settings);
+ await using (writer.ConfigureAwait(false))
+ {
+ await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
+ await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false);
+ if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var id))
+ {
+ await writer.WriteElementStringAsync(null, "id", null, id).ConfigureAwait(false);
+ }
+
+ if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out id))
+ {
+ await writer.WriteElementStringAsync(null, "imdb_id", null, id).ConfigureAwait(false);
+ }
+
+ if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out id))
+ {
+ await writer.WriteElementStringAsync(null, "tmdbid", null, id).ConfigureAwait(false);
+ }
+
+ if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out id))
+ {
+ await writer.WriteElementStringAsync(null, "zap2itid", null, id).ConfigureAwait(false);
+ }
+
+ if (!string.IsNullOrWhiteSpace(timer.Name))
+ {
+ await writer.WriteElementStringAsync(null, "title", null, timer.Name).ConfigureAwait(false);
+ }
+
+ if (!string.IsNullOrWhiteSpace(timer.OfficialRating))
+ {
+ await writer.WriteElementStringAsync(null, "mpaa", null, timer.OfficialRating).ConfigureAwait(false);
+ }
+
+ foreach (var genre in timer.Genres)
+ {
+ await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false);
+ }
+
+ await writer.WriteEndElementAsync().ConfigureAwait(false);
+ await writer.WriteEndDocumentAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task SaveVideoNfoAsync(TimerInfo timer, string recordingPath, BaseItem item, bool lockData)
+ {
+ var nfoPath = Path.ChangeExtension(recordingPath, ".nfo");
+
+ if (File.Exists(nfoPath))
+ {
+ return;
+ }
+
+ var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
+ await using (stream.ConfigureAwait(false))
+ {
+ var settings = new XmlWriterSettings
+ {
+ Indent = true,
+ Encoding = Encoding.UTF8,
+ Async = true
+ };
+
+ var options = _config.GetNfoConfiguration();
+
+ var isSeriesEpisode = timer.IsProgramSeries;
+
+ var writer = XmlWriter.Create(stream, settings);
+ await using (writer.ConfigureAwait(false))
+ {
+ await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
+
+ if (isSeriesEpisode)
+ {
+ await writer.WriteStartElementAsync(null, "episodedetails", null).ConfigureAwait(false);
+
+ if (!string.IsNullOrWhiteSpace(timer.EpisodeTitle))
+ {
+ await writer.WriteElementStringAsync(null, "title", null, timer.EpisodeTitle).ConfigureAwait(false);
+ }
+
+ var premiereDate = item.PremiereDate ?? (!timer.IsRepeat ? DateTime.UtcNow : null);
+
+ if (premiereDate.HasValue)
+ {
+ var formatString = options.ReleaseDateFormat;
+
+ await writer.WriteElementStringAsync(
+ null,
+ "aired",
+ null,
+ premiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ }
+
+ if (item.IndexNumber.HasValue)
+ {
+ await writer.WriteElementStringAsync(null, "episode", null, item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ }
+
+ if (item.ParentIndexNumber.HasValue)
+ {
+ await writer.WriteElementStringAsync(null, "season", null, item.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ await writer.WriteStartElementAsync(null, "movie", null).ConfigureAwait(false);
+
+ if (!string.IsNullOrWhiteSpace(item.Name))
+ {
+ await writer.WriteElementStringAsync(null, "title", null, item.Name).ConfigureAwait(false);
+ }
+
+ if (!string.IsNullOrWhiteSpace(item.OriginalTitle))
+ {
+ await writer.WriteElementStringAsync(null, "originaltitle", null, item.OriginalTitle).ConfigureAwait(false);
+ }
+
+ if (item.PremiereDate.HasValue)
+ {
+ var formatString = options.ReleaseDateFormat;
+
+ await writer.WriteElementStringAsync(
+ null,
+ "premiered",
+ null,
+ item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ await writer.WriteElementStringAsync(
+ null,
+ "releasedate",
+ null,
+ item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ }
+ }
+
+ await writer.WriteElementStringAsync(
+ null,
+ "dateadded",
+ null,
+ DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false);
+
+ if (item.ProductionYear.HasValue)
+ {
+ await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ }
+
+ if (!string.IsNullOrEmpty(item.OfficialRating))
+ {
+ await writer.WriteElementStringAsync(null, "mpaa", null, item.OfficialRating).ConfigureAwait(false);
+ }
+
+ var overview = (item.Overview ?? string.Empty)
+ .StripHtml()
+ .Replace("&quot;", "'", StringComparison.Ordinal);
+
+ await writer.WriteElementStringAsync(null, "plot", null, overview).ConfigureAwait(false);
+
+ if (item.CommunityRating.HasValue)
+ {
+ await writer.WriteElementStringAsync(null, "rating", null, item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ }
+
+ foreach (var genre in item.Genres)
+ {
+ await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false);
+ }
+
+ var people = item.Id.IsEmpty() ? new List<PersonInfo>() : _libraryManager.GetPeople(item);
+
+ var directors = people
+ .Where(i => i.IsType(PersonKind.Director))
+ .Select(i => i.Name)
+ .ToList();
+
+ foreach (var person in directors)
+ {
+ await writer.WriteElementStringAsync(null, "director", null, person).ConfigureAwait(false);
+ }
+
+ var writers = people
+ .Where(i => i.IsType(PersonKind.Writer))
+ .Select(i => i.Name)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ foreach (var person in writers)
+ {
+ await writer.WriteElementStringAsync(null, "writer", null, person).ConfigureAwait(false);
+ }
+
+ foreach (var person in writers)
+ {
+ await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false);
+ }
+
+ var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection);
+
+ if (!string.IsNullOrEmpty(tmdbCollection))
+ {
+ await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false);
+ }
+
+ var imdb = item.GetProviderId(MetadataProvider.Imdb);
+ if (!string.IsNullOrEmpty(imdb))
+ {
+ if (!isSeriesEpisode)
+ {
+ await writer.WriteElementStringAsync(null, "id", null, imdb).ConfigureAwait(false);
+ }
+
+ await writer.WriteElementStringAsync(null, "imdbid", null, imdb).ConfigureAwait(false);
+
+ // No need to lock if we have identified the content already
+ lockData = false;
+ }
+
+ var tvdb = item.GetProviderId(MetadataProvider.Tvdb);
+ if (!string.IsNullOrEmpty(tvdb))
+ {
+ await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false);
+
+ // No need to lock if we have identified the content already
+ lockData = false;
+ }
+
+ var tmdb = item.GetProviderId(MetadataProvider.Tmdb);
+ if (!string.IsNullOrEmpty(tmdb))
+ {
+ await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false);
+
+ // No need to lock if we have identified the content already
+ lockData = false;
+ }
+
+ if (lockData)
+ {
+ await writer.WriteElementStringAsync(null, "lockdata", null, "true").ConfigureAwait(false);
+ }
+
+ if (item.CriticRating.HasValue)
+ {
+ await writer.WriteElementStringAsync(null, "criticrating", null, item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ }
+
+ if (!string.IsNullOrWhiteSpace(item.Tagline))
+ {
+ await writer.WriteElementStringAsync(null, "tagline", null, item.Tagline).ConfigureAwait(false);
+ }
+
+ foreach (var studio in item.Studios)
+ {
+ await writer.WriteElementStringAsync(null, "studio", null, studio).ConfigureAwait(false);
+ }
+
+ await writer.WriteEndElementAsync().ConfigureAwait(false);
+ await writer.WriteEndDocumentAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task SaveRecordingImages(string recordingPath, LiveTvProgram program)
+ {
+ var image = program.IsSeries ?
+ (program.GetImageInfo(ImageType.Thumb, 0) ?? program.GetImageInfo(ImageType.Primary, 0)) :
+ (program.GetImageInfo(ImageType.Primary, 0) ?? program.GetImageInfo(ImageType.Thumb, 0));
+
+ if (image is not null)
+ {
+ try
+ {
+ await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error saving recording image");
+ }
+ }
+
+ if (!program.IsSeries)
+ {
+ image = program.GetImageInfo(ImageType.Backdrop, 0);
+ if (image is not null)
+ {
+ try
+ {
+ await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error saving recording image");
+ }
+ }
+
+ image = program.GetImageInfo(ImageType.Thumb, 0);
+ if (image is not null)
+ {
+ try
+ {
+ await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error saving recording image");
+ }
+ }
+
+ image = program.GetImageInfo(ImageType.Logo, 0);
+ if (image is not null)
+ {
+ try
+ {
+ await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error saving recording image");
+ }
+ }
+ }
+ }
+
+ private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image)
+ {
+ if (!image.IsLocalFile)
+ {
+ image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false);
+ }
+
+ var imageSaveFilenameWithoutExtension = image.Type switch
+ {
+ ImageType.Primary => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "poster",
+ ImageType.Logo => "logo",
+ ImageType.Thumb => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "landscape",
+ ImageType.Backdrop => "fanart",
+ _ => null
+ };
+
+ if (imageSaveFilenameWithoutExtension is null)
+ {
+ return;
+ }
+
+ var imageSavePath = Path.Combine(Path.GetDirectoryName(recordingPath)!, imageSaveFilenameWithoutExtension);
+
+ // preserve original image extension
+ imageSavePath = Path.ChangeExtension(imageSavePath, Path.GetExtension(image.Path));
+
+ File.Copy(image.Path, imageSavePath, true);
+ }
+}
diff --git a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs
deleted file mode 100644
index 18bd61d99..000000000
--- a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs
+++ /dev/null
@@ -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 }
- };
- }
- }
-}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs
index 547ffeb66..18e4810a2 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs
+++ b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs
@@ -9,7 +9,7 @@ using System.Text.Json;
using Jellyfin.Extensions.Json;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.Timers
{
public class ItemDataProvider<T>
where T : class
diff --git a/src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs b/src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs
new file mode 100644
index 000000000..6e8444ba2
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Timers/SeriesTimerManager.cs
@@ -0,0 +1,29 @@
+#pragma warning disable CS1591
+
+using System;
+using System.IO;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.LiveTv;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.Timers
+{
+ public class SeriesTimerManager : ItemDataProvider<SeriesTimerInfo>
+ {
+ public SeriesTimerManager(ILogger<SeriesTimerManager> logger, IConfigurationManager config)
+ : base(
+ logger,
+ Path.Combine(config.CommonApplicationPaths.DataPath, "livetv/seriestimers.json"),
+ (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ }
+
+ /// <inheritdoc />
+ public override void Add(SeriesTimerInfo item)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(item.Id);
+
+ base.Add(item);
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs b/src/Jellyfin.LiveTv/Timers/TimerManager.cs
index 37b1fa14c..da5deea36 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs
+++ b/src/Jellyfin.LiveTv/Timers/TimerManager.cs
@@ -3,21 +3,27 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
+using System.IO;
using System.Linq;
using System.Threading;
using Jellyfin.Data.Events;
+using Jellyfin.LiveTv.Recordings;
+using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.Timers
{
public class TimerManager : ItemDataProvider<TimerInfo>
{
- private readonly ConcurrentDictionary<string, Timer> _timers = new ConcurrentDictionary<string, Timer>(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary<string, Timer> _timers = new(StringComparer.OrdinalIgnoreCase);
- public TimerManager(ILogger logger, string dataPath)
- : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
+ public TimerManager(ILogger<TimerManager> logger, IConfigurationManager config)
+ : base(
+ logger,
+ Path.Combine(config.CommonApplicationPaths.DataPath, "livetv"),
+ (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
{
}
@@ -80,27 +86,16 @@ namespace Jellyfin.LiveTv.EmbyTV
AddOrUpdateSystemTimer(item);
}
- private static bool ShouldStartTimer(TimerInfo item)
- {
- if (item.Status == RecordingStatus.Completed
- || item.Status == RecordingStatus.Cancelled)
- {
- return false;
- }
-
- return true;
- }
-
private void AddOrUpdateSystemTimer(TimerInfo item)
{
StopTimer(item);
- if (!ShouldStartTimer(item))
+ if (item.Status is RecordingStatus.Completed or RecordingStatus.Cancelled)
{
return;
}
- var startDate = RecordingHelper.GetStartTime(item);
+ var startDate = item.StartDate.AddSeconds(-item.PrePaddingSeconds);
var now = DateTime.UtcNow;
if (startDate < now)
@@ -169,13 +164,9 @@ namespace Jellyfin.LiveTv.EmbyTV
}
public TimerInfo? GetTimer(string id)
- {
- return GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase));
- }
+ => GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase));
public TimerInfo? GetTimerByProgramId(string programId)
- {
- return GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase));
- }
+ => GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase));
}
}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
index 3e4b0e13f..60be19c68 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
@@ -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;
diff --git a/src/Jellyfin.Networking/ExternalPortForwarding.cs b/src/Jellyfin.Networking/PortForwardingHost.cs
index df9e43ca9..d01343624 100644
--- a/src/Jellyfin.Networking/ExternalPortForwarding.cs
+++ b/src/Jellyfin.Networking/PortForwardingHost.cs
@@ -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;
diff --git a/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs
new file mode 100644
index 000000000..dd84c1a18
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs
@@ -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);
+ }
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs
index b4960dc0b..6a33a6699 100644
--- a/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs
@@ -1,5 +1,5 @@
using System;
-using Jellyfin.LiveTv.EmbyTV;
+using Jellyfin.LiveTv.Recordings;
using MediaBrowser.Controller.LiveTv;
using Xunit;
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
index 344ac8971..020e20fb8 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
@@ -46,6 +46,8 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.mkv", MediaProtocol.File);
+ Assert.Equal("mkv", res.Container);
+
Assert.Equal(3, res.MediaStreams.Count);
Assert.NotNull(res.VideoStream);
@@ -178,6 +180,21 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
}
[Fact]
+ public void GetMediaInfo_WebM_Success()
+ {
+ var bytes = File.ReadAllBytes("Test Data/Probing/video_webm.json");
+ var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
+
+ MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.webm", MediaProtocol.File);
+
+ Assert.Equal("mkv,webm", res.Container);
+
+ Assert.Equal(2, res.MediaStreams.Count);
+
+ Assert.False(res.MediaStreams[0].IsAVC);
+ }
+
+ [Fact]
public void GetMediaInfo_ProgressiveVideoNoFieldOrder_Success()
{
var bytes = File.ReadAllBytes("Test Data/Probing/video_progressive_no_field_order.json");
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_webm.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_webm.json
new file mode 100644
index 000000000..4cc7db80d
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_webm.json
@@ -0,0 +1,106 @@
+{
+ "streams": [
+ {
+ "index": 0,
+ "codec_name": "vp8",
+ "codec_long_name": "On2 VP8",
+ "profile": "1",
+ "codec_type": "video",
+ "codec_tag_string": "[0][0][0][0]",
+ "codec_tag": "0x0000",
+ "width": 540,
+ "height": 360,
+ "coded_width": 540,
+ "coded_height": 360,
+ "closed_captions": 0,
+ "film_grain": 0,
+ "has_b_frames": 0,
+ "sample_aspect_ratio": "1:1",
+ "display_aspect_ratio": "3:2",
+ "pix_fmt": "yuv420p",
+ "level": -99,
+ "field_order": "progressive",
+ "refs": 1,
+ "r_frame_rate": "2997/125",
+ "avg_frame_rate": "2997/125",
+ "time_base": "1/1000",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "disposition": {
+ "default": 1,
+ "dub": 0,
+ "original": 0,
+ "comment": 0,
+ "lyrics": 0,
+ "karaoke": 0,
+ "forced": 0,
+ "hearing_impaired": 0,
+ "visual_impaired": 0,
+ "clean_effects": 0,
+ "attached_pic": 0,
+ "timed_thumbnails": 0,
+ "captions": 0,
+ "descriptions": 0,
+ "metadata": 0,
+ "dependent": 0,
+ "still_image": 0
+ },
+ "tags": {
+ "language": "eng"
+ }
+ },
+ {
+ "index": 1,
+ "codec_name": "vorbis",
+ "codec_long_name": "Vorbis",
+ "codec_type": "audio",
+ "codec_tag_string": "[0][0][0][0]",
+ "codec_tag": "0x0000",
+ "sample_fmt": "fltp",
+ "sample_rate": "44100",
+ "channels": 1,
+ "channel_layout": "mono",
+ "bits_per_sample": 0,
+ "r_frame_rate": "0/0",
+ "avg_frame_rate": "0/0",
+ "time_base": "1/1000",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "extradata_size": 3097,
+ "disposition": {
+ "default": 1,
+ "dub": 0,
+ "original": 0,
+ "comment": 0,
+ "lyrics": 0,
+ "karaoke": 0,
+ "forced": 0,
+ "hearing_impaired": 0,
+ "visual_impaired": 0,
+ "clean_effects": 0,
+ "attached_pic": 0,
+ "timed_thumbnails": 0,
+ "captions": 0,
+ "descriptions": 0,
+ "metadata": 0,
+ "dependent": 0,
+ "still_image": 0
+ },
+ "tags": {
+ "language": "eng"
+ }
+ }
+ ],
+ "format": {
+ "filename": "sample.webm",
+ "nb_streams": 2,
+ "nb_programs": 0,
+ "format_name": "matroska,webm",
+ "format_long_name": "Matroska / WebM",
+ "start_time": "0.000000",
+ "duration": "117.700914",
+ "size": "8566268",
+ "bit_rate": "582239",
+ "probe_score": 100
+ }
+}
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index 2f84fa544..d9dceee55 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -28,7 +28,7 @@ namespace Jellyfin.Model.Tests
[InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")]
[InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")]
[InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
- [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// Firefox
[InlineData("Firefox", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450
@@ -39,7 +39,7 @@ namespace Jellyfin.Model.Tests
[InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")]
[InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")]
[InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
- [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// Safari
[InlineData("SafariNext", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450
@@ -90,7 +90,7 @@ namespace Jellyfin.Model.Tests
[InlineData("Chrome-NoHLS", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")]
[InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "http")]
[InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
- [InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome-NoHLS", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// TranscodeMedia
[InlineData("TranscodeMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
@@ -178,7 +178,7 @@ namespace Jellyfin.Model.Tests
[InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")]
[InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")]
[InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
- [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// Firefox
[InlineData("Firefox", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450
@@ -188,7 +188,7 @@ namespace Jellyfin.Model.Tests
[InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")]
[InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")]
[InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
- [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// Safari
[InlineData("SafariNext", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450
diff --git a/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs
index 371c3811a..a18a85ec0 100644
--- a/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs
+++ b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs
@@ -6,6 +6,11 @@ namespace Jellyfin.Model.Tests.Net
public class MimeTypesTests
{
[Theory]
+ [InlineData(".cb7", "application/x-cb7")]
+ [InlineData(".cba", "application/x-cba")]
+ [InlineData(".cbr", "application/vnd.comicbook-rar")]
+ [InlineData(".cbt", "application/x-cbt")]
+ [InlineData(".cbz", "application/vnd.comicbook+zip")]
[InlineData(".dll", "application/octet-stream")]
[InlineData(".log", "text/plain")]
[InlineData(".srt", "application/x-subrip")]
@@ -69,7 +74,7 @@ namespace Jellyfin.Model.Tests.Net
[InlineData(".dsp", "audio/dsp")]
[InlineData(".flac", "audio/flac")]
[InlineData(".m4a", "audio/mp4")]
- [InlineData(".m4b", "audio/m4b")]
+ [InlineData(".m4b", "audio/mp4")]
[InlineData(".mid", "audio/midi")]
[InlineData(".midi", "audio/midi")]
[InlineData(".mp3", "audio/mpeg")]
@@ -94,10 +99,16 @@ namespace Jellyfin.Model.Tests.Net
[InlineData("application/pdf", ".pdf")]
[InlineData("application/ttml+xml", ".ttml")]
[InlineData("application/vnd.amazon.ebook", ".azw")]
+ [InlineData("application/vnd.comicbook-rar", ".cbr")]
+ [InlineData("application/vnd.comicbook+zip", ".cbz")]
[InlineData("application/vnd.ms-fontobject", ".eot")]
[InlineData("application/vnd.rar", ".rar")]
[InlineData("application/wasm", ".wasm")]
[InlineData("application/x-7z-compressed", ".7z")]
+ [InlineData("application/x-cb7", ".cb7")]
+ [InlineData("application/x-cba", ".cba")]
+ [InlineData("application/x-cbr", ".cbr")]
+ [InlineData("application/x-cbt", ".cbt")]
[InlineData("application/x-cbz", ".cbz")]
[InlineData("application/x-javascript", ".js")]
[InlineData("application/x-mobipocket-ebook", ".mobi")]
diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json
index da185aacf..e528281bd 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json
@@ -1,7 +1,7 @@
{
"Id": "a766d122b58e45d9492d17af66748bf5",
"Path": "/Media/MyVideo-720p.mkv",
- "Container": "mkv,webm",
+ "Container": "mkv",
"Size": 835317696,
"Name": "MyVideo-1080p",
"ETag": "579a34c6d5dfb23f61539a51220b6a23",
diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json
index 0a85a1353..8ef10ae87 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json
@@ -1,7 +1,7 @@
{
"Id": "a766d122b58e45d9492d17af66748bf5",
"Path": "/Media/MyVideo-720p.mkv",
- "Container": "mkv,webm",
+ "Container": "mkv",
"Size": 835317696,
"Name": "MyVideo-1080p",
"ETag": "579a34c6d5dfb23f61539a51220b6a23",
diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json
index 2b932ff52..80a9f4103 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json
@@ -1,7 +1,7 @@
{
"Id": "a766d122b58e45d9492d17af66748bf5",
"Path": "/Media/MyVideo-720p.mkv",
- "Container": "mkv,webm",
+ "Container": "mkv",
"Size": 835317696,
"Name": "MyVideo-1080p",
"ETag": "579a34c6d5dfb23f61539a51220b6a23",
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
index 1e0851993..478db6941 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Configuration;
@@ -570,7 +571,8 @@ namespace Jellyfin.Providers.Tests.Manager
Mock.Of<IFileSystem>(),
Mock.Of<IServerApplicationPaths>(),
libraryManager.Object,
- baseItemManager!);
+ baseItemManager!,
+ Mock.Of<ILyricManager>());
return providerManager;
}
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs
index 33a9aca31..d5f6873a2 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs
@@ -26,7 +26,7 @@ public class AudioResolverTests
public AudioResolverTests()
{
// prep BaseItem and Video for calls made that expect managers
- Video.LiveTvManager = Mock.Of<ILiveTvManager>();
+ Video.RecordingsManager = Mock.Of<IRecordingsManager>();
var applicationPaths = new Mock<IServerApplicationPaths>().Object;
var serverConfig = new Mock<IServerConfigurationManager>();
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
index 2b3867512..58b67ae55 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
@@ -37,7 +37,7 @@ public class MediaInfoResolverTests
public MediaInfoResolverTests()
{
// prep BaseItem and Video for calls made that expect managers
- Video.LiveTvManager = Mock.Of<ILiveTvManager>();
+ Video.RecordingsManager = Mock.Of<IRecordingsManager>();
var applicationPaths = new Mock<IServerApplicationPaths>().Object;
var serverConfig = new Mock<IServerConfigurationManager>();
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs
index 0c1c269a4..8077bd791 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs
@@ -26,7 +26,7 @@ public class SubtitleResolverTests
public SubtitleResolverTests()
{
// prep BaseItem and Video for calls made that expect managers
- Video.LiveTvManager = Mock.Of<ILiveTvManager>();
+ Video.RecordingsManager = Mock.Of<IRecordingsManager>();
var applicationPaths = new Mock<IServerApplicationPaths>().Object;
var serverConfig = new Mock<IServerConfigurationManager>();