aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ci/azure-pipelines-abi.yml93
-rw-r--r--.ci/azure-pipelines-main.yml71
-rw-r--r--.ci/azure-pipelines-package.yml274
-rw-r--r--.ci/azure-pipelines-test.yml98
-rw-r--r--.ci/azure-pipelines.yml64
-rw-r--r--.config/dotnet-tools.json2
-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.yml4
-rw-r--r--.github/workflows/ci-tests.yml2
-rw-r--r--CONTRIBUTORS.md1
-rw-r--r--Directory.Packages.props32
-rw-r--r--Emby.Naming/Common/NamingOptions.cs12
-rw-r--r--Emby.Naming/ExternalFiles/ExternalPathParser.cs3
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs2
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs21
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs29
-rw-r--r--Emby.Server.Implementations/Localization/Core/ga.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/hi.json28
-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/ImageController.cs338
-rw-r--r--Jellyfin.Api/Controllers/InstantMixController.cs40
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs130
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs1
-rw-r--r--Jellyfin.Api/Controllers/LiveTvController.cs59
-rw-r--r--Jellyfin.Api/Controllers/LyricsController.cs265
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs3
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs7
-rw-r--r--Jellyfin.Api/Controllers/PlaystateController.cs168
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs46
-rw-r--r--Jellyfin.Api/Controllers/SuggestionsController.cs43
-rw-r--r--Jellyfin.Api/Controllers/UniversalAudioController.cs9
-rw-r--r--Jellyfin.Api/Controllers/UserController.cs92
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs330
-rw-r--r--Jellyfin.Api/Controllers/UserViewsController.cs61
-rw-r--r--Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs2
-rw-r--r--Jellyfin.Data/Entities/User.cs1
-rw-r--r--Jellyfin.Data/Enums/MediaStreamProtocol.cs20
-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/Users/UserManager.cs1
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs2
-rw-r--r--Jellyfin.Server/Migrations/MigrationRunner.cs3
-rw-r--r--Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs52
-rw-r--r--Jellyfin.Server/Startup.cs5
-rw-r--r--MediaBrowser.Common/Api/Policies.cs5
-rw-r--r--MediaBrowser.Controller/Entities/Audio/Audio.cs12
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs4
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs12
-rw-r--r--MediaBrowser.Controller/LiveTv/IListingsManager.cs79
-rw-r--r--MediaBrowser.Controller/LiveTv/ILiveTvManager.cs54
-rw-r--r--MediaBrowser.Controller/LiveTv/IRecordingsManager.cs55
-rw-r--r--MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs17
-rw-r--r--MediaBrowser.Controller/Lyrics/ILyricManager.cs111
-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/EncodingHelper.cs2
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs158
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs2
-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.cs12
-rw-r--r--MediaBrowser.Model/Dlna/StreamInfo.cs14
-rw-r--r--MediaBrowser.Model/Dlna/TranscodingProfile.cs3
-rw-r--r--MediaBrowser.Model/Drawing/ImageFormat.cs17
-rw-r--r--MediaBrowser.Model/Drawing/ImageFormatExtensions.cs2
-rw-r--r--MediaBrowser.Model/Dto/MediaSourceInfo.cs4
-rw-r--r--MediaBrowser.Model/Entities/MediaStreamType.cs7
-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.cs11
-rw-r--r--MediaBrowser.Model/Providers/LyricProviderInfo.cs17
-rw-r--r--MediaBrowser.Model/Providers/RemoteLyricInfo.cs29
-rw-r--r--MediaBrowser.Model/Session/GeneralCommandType.cs3
-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/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.cs441
-rw-r--r--MediaBrowser.Providers/Lyric/TxtLyricParser.cs11
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs130
-rw-r--r--MediaBrowser.Providers/MediaBrowser.Providers.csproj1
-rw-r--r--MediaBrowser.Providers/MediaInfo/AudioFileProber.cs131
-rw-r--r--MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs27
-rw-r--r--MediaBrowser.Providers/MediaInfo/LyricResolver.cs39
-rw-r--r--MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs97
-rw-r--r--MediaBrowser.Providers/MediaInfo/ProbeProvider.cs56
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs3
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs14
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs16
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs16
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs13
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs2
-rw-r--r--deployment/Dockerfile.centos.amd642
-rw-r--r--deployment/Dockerfile.fedora.amd642
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaEncoder.cs43
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs49
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs31
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs2
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs5
-rw-r--r--src/Jellyfin.Extensions/Json/JsonDefaults.cs1
-rw-r--r--src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs27
-rw-r--r--src/Jellyfin.Extensions/StringExtensions.cs10
-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.cs2540
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs31
-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.cs11
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs50
-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/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/LiveTvManager.cs383
-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)2
-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/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--tests/Jellyfin.Extensions.Tests/Json/Converters/JsonDefaultStringEnumConverterTests.cs112
-rw-r--r--tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs2
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs54
-rw-r--r--tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs4
-rw-r--r--tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs11
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-aac-srt-2600k.json2
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-srt-2600k.json2
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-ac3-aac-srt-15200k.json2
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-ac3-aacDef-srt-15200k.json89
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs6
-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
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest.json (renamed from tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json)0
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs6
159 files changed, 6726 insertions, 4573 deletions
diff --git a/.ci/azure-pipelines-abi.yml b/.ci/azure-pipelines-abi.yml
deleted file mode 100644
index 547a514f8..000000000
--- a/.ci/azure-pipelines-abi.yml
+++ /dev/null
@@ -1,93 +0,0 @@
-parameters:
-- name: Packages
- type: object
- default: {}
-- name: LinuxImage
- type: string
- default: "ubuntu-latest"
-- name: DotNetSdkVersion
- type: string
- default: 8.0.x
-
-jobs:
- - job: CompatibilityCheck
- displayName: Compatibility Check
- dependsOn: Build
- condition: and(succeeded(), variables['System.PullRequest.PullRequestNumber'])
-
- pool:
- vmImage: "${{ parameters.LinuxImage }}"
-
- strategy:
- matrix:
- ${{ each Package in parameters.Packages }}:
- ${{ Package.key }}:
- NugetPackageName: ${{ Package.value.NugetPackageName }}
- AssemblyFileName: ${{ Package.value.AssemblyFileName }}
- maxParallel: 2
-
- steps:
- - checkout: none
-
- - task: UseDotNet@2
- displayName: "Update DotNet"
- inputs:
- packageType: sdk
- version: ${{ parameters.DotNetSdkVersion }}
-
- - task: DotNetCoreCLI@2
- displayName: 'Install ABI CompatibilityChecker Tool'
- inputs:
- command: custom
- custom: tool
- arguments: 'update compatibilitychecker -g'
-
- - task: DownloadPipelineArtifact@2
- displayName: 'Download New Assembly Build Artifact'
- inputs:
- source: 'current'
- artifact: "$(NugetPackageName)"
- path: "$(System.ArtifactsDirectory)/new-artifacts"
- runVersion: "latest"
-
- - task: CopyFiles@2
- displayName: 'Copy New Assembly Build Artifact'
- inputs:
- sourceFolder: $(System.ArtifactsDirectory)/new-artifacts
- contents: '**/*.dll'
- targetFolder: $(System.ArtifactsDirectory)/new-release
- cleanTargetFolder: true
- overWrite: true
- flattenFolders: true
-
- - task: DownloadPipelineArtifact@2
- displayName: 'Download Reference Assembly Build Artifact'
- enabled: false
- inputs:
- source: "specific"
- artifact: "$(NugetPackageName)"
- path: "$(System.ArtifactsDirectory)/current-artifacts"
- project: "$(System.TeamProjectId)"
- pipeline: "$(System.DefinitionId)"
- runVersion: "latestFromBranch"
- runBranch: "refs/heads/$(System.PullRequest.TargetBranch)"
-
- - task: CopyFiles@2
- displayName: 'Copy Reference Assembly Build Artifact'
- enabled: false
- inputs:
- sourceFolder: $(System.ArtifactsDirectory)/current-artifacts
- contents: '**/*.dll'
- targetFolder: $(System.ArtifactsDirectory)/current-release
- cleanTargetFolder: true
- overWrite: true
- flattenFolders: true
-
- - task: DotNetCoreCLI@2
- displayName: 'Execute ABI Compatibility Check Tool'
- enabled: false
- inputs:
- command: custom
- custom: compat
- arguments: 'current-release/$(AssemblyFileName) new-release/$(AssemblyFileName) --azure-pipelines --warnings-only'
- workingDirectory: $(System.ArtifactsDirectory)
diff --git a/.ci/azure-pipelines-main.yml b/.ci/azure-pipelines-main.yml
deleted file mode 100644
index 0702aeb6b..000000000
--- a/.ci/azure-pipelines-main.yml
+++ /dev/null
@@ -1,71 +0,0 @@
-parameters:
- LinuxImage: 'ubuntu-latest'
- RestoreBuildProjects: 'Jellyfin.Server/Jellyfin.Server.csproj'
- DotNetSdkVersion: 8.0.x
-
-jobs:
- - job: Build
- displayName: Build
- strategy:
- matrix:
- Release:
- BuildConfiguration: Release
- Debug:
- BuildConfiguration: Debug
- pool:
- vmImage: '${{ parameters.LinuxImage }}'
- steps:
- - checkout: self
- clean: true
- submodules: true
- persistCredentials: true
-
- - task: UseDotNet@2
- displayName: 'Update DotNet'
- inputs:
- packageType: sdk
- version: ${{ parameters.DotNetSdkVersion }}
-
- - task: DotNetCoreCLI@2
- displayName: 'Publish Server'
- inputs:
- command: publish
- publishWebProjects: false
- projects: '${{ parameters.RestoreBuildProjects }}'
- arguments: '--configuration $(BuildConfiguration) --output $(Build.ArtifactStagingDirectory)'
- zipAfterPublish: false
-
- - task: PublishPipelineArtifact@1
- displayName: 'Publish Artifact Naming'
- condition: and(succeeded(), eq(variables['BuildConfiguration'], 'Release'))
- inputs:
- targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/Emby.Naming.dll'
- artifactName: 'Jellyfin.Naming'
-
- - task: PublishPipelineArtifact@1
- displayName: 'Publish Artifact Controller'
- condition: and(succeeded(), eq(variables['BuildConfiguration'], 'Release'))
- inputs:
- targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/MediaBrowser.Controller.dll'
- artifactName: 'Jellyfin.Controller'
-
- - task: PublishPipelineArtifact@1
- displayName: 'Publish Artifact Model'
- condition: and(succeeded(), eq(variables['BuildConfiguration'], 'Release'))
- inputs:
- targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/MediaBrowser.Model.dll'
- artifactName: 'Jellyfin.Model'
-
- - task: PublishPipelineArtifact@1
- displayName: 'Publish Artifact Common'
- condition: and(succeeded(), eq(variables['BuildConfiguration'], 'Release'))
- inputs:
- targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/MediaBrowser.Common.dll'
- artifactName: 'Jellyfin.Common'
-
- - task: PublishPipelineArtifact@1
- displayName: 'Publish Artifact Extensions'
- condition: and(succeeded(), eq(variables['BuildConfiguration'], 'Release'))
- inputs:
- targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/Jellyfin.Extensions.dll'
- artifactName: 'Jellyfin.Extensions'
diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml
deleted file mode 100644
index b0684c0d4..000000000
--- a/.ci/azure-pipelines-package.yml
+++ /dev/null
@@ -1,274 +0,0 @@
-jobs:
-- job: BuildPackage
- displayName: 'Build Packages'
-
- strategy:
- matrix:
- CentOS.amd64:
- BuildConfiguration: centos.amd64
- Fedora.amd64:
- BuildConfiguration: fedora.amd64
- Debian.amd64:
- BuildConfiguration: debian.amd64
- Debian.arm64:
- BuildConfiguration: debian.arm64
- Debian.armhf:
- BuildConfiguration: debian.armhf
- Ubuntu.amd64:
- BuildConfiguration: ubuntu.amd64
- Ubuntu.arm64:
- BuildConfiguration: ubuntu.arm64
- Ubuntu.armhf:
- BuildConfiguration: ubuntu.armhf
- Linux.amd64:
- BuildConfiguration: linux.amd64
- Linux.amd64-musl:
- BuildConfiguration: linux.amd64-musl
- Linux.arm64:
- BuildConfiguration: linux.arm64
- Linux.musl-linux-arm64:
- BuildConfiguration: linux.musl-linux-arm64
- Linux.armhf:
- BuildConfiguration: linux.armhf
- Windows.amd64:
- BuildConfiguration: windows.amd64
- MacOS.amd64:
- BuildConfiguration: macos.amd64
- MacOS.arm64:
- BuildConfiguration: macos.arm64
- Portable:
- BuildConfiguration: portable
-
- pool:
- vmImage: 'ubuntu-latest'
-
- steps:
- - script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )"
- displayName: Set release version (stable)
- condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
-
- - script: 'docker build -f deployment/Dockerfile.$(BuildConfiguration) -t jellyfin-server-$(BuildConfiguration) --label "org.opencontainers.image.url=$(Build.Repository.Uri)" --label "org.opencontainers.image.revision=$(Build.SourceVersion)" deployment'
- displayName: 'Build Dockerfile'
-
- - script: 'docker image ls -a && docker run -v $(pwd)/deployment/dist:/dist -v $(pwd):/jellyfin -e IS_UNSTABLE="yes" -e BUILD_ID=$(Build.BuildNumber) jellyfin-server-$(BuildConfiguration)'
- displayName: 'Run Dockerfile (unstable)'
- condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
-
- - script: 'docker image ls -a && docker run -v $(pwd)/deployment/dist:/dist -v $(pwd):/jellyfin -e IS_UNSTABLE="no" -e BUILD_ID=$(Build.BuildNumber) jellyfin-server-$(BuildConfiguration)'
- displayName: 'Run Dockerfile (stable)'
- condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
-
- - task: PublishPipelineArtifact@1
- displayName: 'Publish Release'
- inputs:
- targetPath: '$(Build.SourcesDirectory)/deployment/dist'
- artifactName: 'jellyfin-server-$(BuildConfiguration)'
-
- - task: SSH@0
- displayName: 'Create target directory on repository server'
- inputs:
- sshEndpoint: repository
- runOptions: 'inline'
- inline: 'mkdir -p /srv/repository/incoming/azure/$(Build.BuildNumber)/$(BuildConfiguration)'
-
- - task: CopyFilesOverSSH@0
- displayName: 'Upload artifacts to repository server'
- inputs:
- sshEndpoint: repository
- sourceFolder: '$(Build.SourcesDirectory)/deployment/dist'
- contents: '**'
- targetFolder: '/srv/repository/incoming/azure/$(Build.BuildNumber)/$(BuildConfiguration)'
-
-- job: OpenAPISpec
- dependsOn: Test
- condition: or(startsWith(variables['Build.SourceBranch'], 'refs/heads/master'),startsWith(variables['Build.SourceBranch'], 'refs/tags/v'))
- displayName: 'Push OpenAPI Spec to repository'
-
- pool:
- vmImage: 'ubuntu-latest'
-
- steps:
- - script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )"
- displayName: Set release version (stable)
- condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
-
- - task: DownloadPipelineArtifact@2
- displayName: 'Download OpenAPI Spec'
- inputs:
- source: 'current'
- artifact: "OpenAPI Spec"
- path: "$(System.ArtifactsDirectory)/openapispec"
- runVersion: "latest"
-
- - task: SSH@0
- displayName: 'Create target directory on repository server'
- inputs:
- sshEndpoint: repository
- runOptions: 'inline'
- inline: 'mkdir -p /srv/repository/incoming/azure/$(Build.BuildNumber)'
-
- - task: CopyFilesOverSSH@0
- displayName: 'Upload artifacts to repository server'
- inputs:
- sshEndpoint: repository
- sourceFolder: '$(System.ArtifactsDirectory)/openapispec'
- contents: 'openapi.json'
- targetFolder: '/srv/repository/incoming/azure/$(Build.BuildNumber)'
-
-- job: BuildDocker
- displayName: 'Build Docker'
-
- strategy:
- matrix:
- amd64:
- BuildConfiguration: amd64
- arm64:
- BuildConfiguration: arm64
- armhf:
- BuildConfiguration: armhf
-
- pool:
- vmImage: 'ubuntu-latest'
-
- variables:
- - name: JellyfinVersion
- value: 0.0.0
-
- steps:
- - script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )"
- displayName: Set release version (stable)
- condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
-
- - task: Docker@2
- displayName: 'Push Unstable Image'
- condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
- inputs:
- repository: 'jellyfin/jellyfin-server'
- command: buildAndPush
- buildContext: '.'
- Dockerfile: 'deployment/Dockerfile.docker.$(BuildConfiguration)'
- containerRegistry: Docker Hub
- tags: |
- unstable-$(Build.BuildNumber)-$(BuildConfiguration)
- unstable-$(BuildConfiguration)
-
- - task: Docker@2
- displayName: 'Push Stable Image'
- condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
- inputs:
- repository: 'jellyfin/jellyfin-server'
- command: buildAndPush
- buildContext: '.'
- Dockerfile: 'deployment/Dockerfile.docker.$(BuildConfiguration)'
- containerRegistry: Docker Hub
- tags: |
- stable-$(Build.BuildNumber)-$(BuildConfiguration)
- $(JellyfinVersion)-$(BuildConfiguration)
-
-- job: CollectArtifacts
- timeoutInMinutes: 20
- displayName: 'Collect Artifacts'
- condition: succeededOrFailed()
- continueOnError: true
- dependsOn:
- - BuildPackage
- - BuildDocker
-
- pool:
- vmImage: 'ubuntu-latest'
-
- steps:
- - task: SSH@0
- displayName: 'Update Unstable Repository'
- continueOnError: true
- condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
- inputs:
- sshEndpoint: repository
- runOptions: 'commands'
- commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable &
-
- - task: SSH@0
- displayName: 'Update Stable Repository'
- continueOnError: true
- condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
- inputs:
- sshEndpoint: repository
- runOptions: 'commands'
- commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) $(Build.SourceBranch) &
-
-- job: PublishNuget
- displayName: 'Publish NuGet packages'
-
- pool:
- vmImage: 'ubuntu-latest'
-
- variables:
- - name: JellyfinVersion
- value: $[replace(variables['Build.SourceBranch'],'refs/tags/v','')]
-
- steps:
- - task: UseDotNet@2
- displayName: 'Use .NET 8.0 sdk'
- inputs:
- packageType: 'sdk'
- version: '8.0.x'
-
- - task: DotNetCoreCLI@2
- displayName: 'Build Stable Nuget packages'
- condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
- inputs:
- command: 'custom'
- projects: |
- Jellyfin.Data/Jellyfin.Data.csproj
- MediaBrowser.Common/MediaBrowser.Common.csproj
- MediaBrowser.Controller/MediaBrowser.Controller.csproj
- MediaBrowser.Model/MediaBrowser.Model.csproj
- Emby.Naming/Emby.Naming.csproj
- src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
- custom: 'pack'
- arguments: -o $(Build.ArtifactStagingDirectory) -p:Version=$(JellyfinVersion)
-
- - task: DotNetCoreCLI@2
- displayName: 'Build Unstable Nuget packages'
- condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
- inputs:
- command: 'custom'
- projects: |
- Jellyfin.Data/Jellyfin.Data.csproj
- MediaBrowser.Common/MediaBrowser.Common.csproj
- MediaBrowser.Controller/MediaBrowser.Controller.csproj
- MediaBrowser.Model/MediaBrowser.Model.csproj
- Emby.Naming/Emby.Naming.csproj
- src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
- custom: 'pack'
- arguments: '--version-suffix $(Build.BuildNumber) -o $(Build.ArtifactStagingDirectory) -p:Stability=Unstable'
-
- - task: PublishBuildArtifacts@1
- displayName: 'Publish Nuget packages'
- inputs:
- pathToPublish: $(Build.ArtifactStagingDirectory)
- artifactName: Jellyfin Nuget Packages
-
- - task: NuGetCommand@2
- displayName: 'Push Nuget packages to stable feed'
- condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
- inputs:
- command: 'push'
- packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg'
- nuGetFeedType: 'external'
- publishFeedCredentials: 'NugetOrg'
- allowPackageConflicts: true # This ignores an error if the version already exists
-
- - task: NuGetAuthenticate@1
- displayName: 'Authenticate to unstable Nuget feed'
- condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
-
- - task: NuGetCommand@2
- displayName: 'Push Nuget packages to unstable feed'
- condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
- inputs:
- command: 'push'
- packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg;!$(Build.ArtifactStagingDirectory)/**/*.symbols.nupkg' # No symbols since Azure Artifact does not support it
- nuGetFeedType: 'internal'
- publishVstsFeed: '7cce6c46-d610-45e3-9fb7-65a6bfd1b671/a5746b79-f369-42db-93ff-59cd066f9327'
- allowPackageConflicts: true # This ignores an error if the version already exists
diff --git a/.ci/azure-pipelines-test.yml b/.ci/azure-pipelines-test.yml
deleted file mode 100644
index 3549c691c..000000000
--- a/.ci/azure-pipelines-test.yml
+++ /dev/null
@@ -1,98 +0,0 @@
-parameters:
-- name: ImageNames
- type: object
- default:
- Linux: "ubuntu-latest"
- Windows: "windows-latest"
- macOS: "macos-latest"
-- name: TestProjects
- type: string
- default: "tests/**/*Tests.csproj"
-- name: DotNetSdkVersion
- type: string
- default: 8.0.x
-
-jobs:
- - job: Test
- displayName: Test
- strategy:
- matrix:
- ${{ each imageName in parameters.ImageNames }}:
- ${{ imageName.key }}:
- ImageName: ${{ imageName.value }}
- pool:
- vmImage: "$(ImageName)"
- steps:
- - checkout: self
- clean: true
- submodules: true
- persistCredentials: false
-
- # This is required for the SonarCloud analyzer
- - task: UseDotNet@2
- displayName: "Install .NET SDK 5.x"
- condition: eq(variables['ImageName'], 'ubuntu-latest')
- inputs:
- packageType: sdk
- version: '5.x'
-
- - task: UseDotNet@2
- displayName: "Update DotNet"
- inputs:
- packageType: sdk
- version: ${{ parameters.DotNetSdkVersion }}
-
- - task: SonarCloudPrepare@1
- displayName: 'Prepare analysis on SonarCloud'
- condition: eq(variables['ImageName'], 'ubuntu-latest')
- enabled: false
- inputs:
- SonarCloud: 'Sonarcloud for Jellyfin'
- organization: 'jellyfin'
- projectKey: 'jellyfin_jellyfin'
-
- - task: DotNetCoreCLI@2
- displayName: 'Run CLI Tests'
- inputs:
- command: "test"
- projects: ${{ parameters.TestProjects }}
- arguments: '--configuration Release --collect:"XPlat Code Coverage" --settings tests/coverletArgs.runsettings --verbosity minimal'
- publishTestResults: true
- testRunTitle: $(Agent.JobName)
- workingDirectory: "$(Build.SourcesDirectory)"
-
- - task: SonarCloudAnalyze@1
- displayName: 'Run Code Analysis'
- condition: eq(variables['ImageName'], 'ubuntu-latest')
- enabled: false
-
- - task: SonarCloudPublish@1
- displayName: 'Publish Quality Gate Result'
- condition: eq(variables['ImageName'], 'ubuntu-latest')
- enabled: false
-
- - task: Palmmedia.reportgenerator.reportgenerator-build-release-task.reportgenerator@4
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) # !! THIS is for V1 only V2 will/should support merging
- displayName: 'Run ReportGenerator'
- inputs:
- reports: "$(Agent.TempDirectory)/**/coverage.cobertura.xml"
- targetdir: "$(Agent.TempDirectory)/merged/"
- reporttypes: "Cobertura"
-
- ## V2 is already in the repository but it does not work "wrong number of segments" YAML error.
- - task: PublishCodeCoverageResults@1
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) # !! THIS is for V1 only V2 will/should support merging
- displayName: 'Publish Code Coverage'
- inputs:
- codeCoverageTool: "cobertura"
- #summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml' # !!THIS IS FOR V2
- summaryFileLocation: "$(Agent.TempDirectory)/merged/**.xml"
- pathToSources: $(Build.SourcesDirectory)
- failIfCoverageEmpty: true
-
- - task: PublishPipelineArtifact@1
- displayName: 'Publish OpenAPI Artifact'
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
- inputs:
- targetPath: "tests/Jellyfin.Server.Integration.Tests/bin/Release/net8.0/openapi.json"
- artifactName: 'OpenAPI Spec'
diff --git a/.ci/azure-pipelines.yml b/.ci/azure-pipelines.yml
deleted file mode 100644
index 19c9caacb..000000000
--- a/.ci/azure-pipelines.yml
+++ /dev/null
@@ -1,64 +0,0 @@
-name: $(Date:yyyyMMdd)$(Rev:.r)
-
-variables:
-- name: TestProjects
- value: 'tests/**/*Tests.csproj'
-- name: RestoreBuildProjects
- value: 'Jellyfin.Server/Jellyfin.Server.csproj'
-
-pr:
- autoCancel: true
-
-trigger:
- batch: true
- branches:
- include:
- - '*'
- tags:
- include:
- - 'v*'
-
-jobs:
-- ${{ if not(startsWith(variables['Build.SourceBranch'], 'refs/tags/v')) }}:
- - template: azure-pipelines-main.yml
- parameters:
- LinuxImage: 'ubuntu-latest'
- RestoreBuildProjects: $(RestoreBuildProjects)
-
-- ${{ if not(or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master'))) }}:
- - template: azure-pipelines-test.yml
- parameters:
- ImageNames:
- Linux: 'ubuntu-latest'
- Windows: 'windows-latest'
- macOS: 'macos-latest'
-
-- ${{ if or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) }}:
- - template: azure-pipelines-test.yml
- parameters:
- ImageNames:
- Linux: 'ubuntu-latest'
-
-- ${{ if not(or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master'))) }}:
- - template: azure-pipelines-abi.yml
- parameters:
- Packages:
- Naming:
- NugetPackageName: Jellyfin.Naming
- AssemblyFileName: Emby.Naming.dll
- Controller:
- NugetPackageName: Jellyfin.Controller
- AssemblyFileName: MediaBrowser.Controller.dll
- Model:
- NugetPackageName: Jellyfin.Model
- AssemblyFileName: MediaBrowser.Model.dll
- Common:
- NugetPackageName: Jellyfin.Common
- AssemblyFileName: MediaBrowser.Common.dll
- Extensions:
- NugetPackageName: Jellyfin.Extensions
- AssemblyFileName: Jellyfin.Extensions.dll
- LinuxImage: 'ubuntu-latest'
-
-- ${{ if or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) }}:
- - template: azure-pipelines-package.yml
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/.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 1ae7658eb..20307dd7d 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@e675ced7a7522a761fc9c8eb26682c8b27c42b2b # v3.24.1
+ uses: github/codeql-action/init@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@e675ced7a7522a761fc9c8eb26682c8b27c42b2b # v3.24.1
+ uses: github/codeql-action/autobuild@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@e675ced7a7522a761fc9c8eb26682c8b27c42b2b # v3.24.1
+ uses: github/codeql-action/analyze@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml
index 97f1a33e7..b5ccafb86 100644
--- a/.github/workflows/ci-openapi.yml
+++ b/.github/workflows/ci-openapi.yml
@@ -78,12 +78,12 @@ jobs:
- openapi-base
steps:
- name: Download openapi-head
- uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2
+ uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
name: openapi-head
path: openapi-head
- name: Download openapi-base
- uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2
+ uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
name: openapi-base
path: openapi-base
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 4b5db14ae..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@68f1963d9876d2ac78bfd1c41c395514b7318855 # 5.2.1
+ uses: danielpalme/ReportGenerator-GitHub-Action@b067e0c5d288fb4277b9f397b2dc6013f60381f0 # 5.2.2
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged/"
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index a8ee693ec..55642e4e2 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -251,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 97173d196..7400edd93 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,11 +12,11 @@
<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.3" />
<PackageVersion Include="FsCheck.Xunit" Version="2.16.6" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.1" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.7" />
@@ -24,15 +24,15 @@
<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" />
@@ -41,13 +41,13 @@
<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.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" />
@@ -72,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.13" />
+ <PackageVersion Include="Svg.Skia" Version="1.0.0.14" />
<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 550c16b4c..745753440 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -630,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>();
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/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 7998ce34a..a2abafd2a 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1232,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())
@@ -1847,7 +1860,7 @@ namespace Emby.Server.Implementations.Library
try
{
var index = item.GetImageIndex(img);
- image = await ConvertImageToLocal(item, img, index).ConfigureAwait(false);
+ image = await ConvertImageToLocal(item, img, index, removeOnFailure: true).ConfigureAwait(false);
}
catch (ArgumentException)
{
@@ -2774,7 +2787,7 @@ namespace Emby.Server.Implementations.Library
await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false);
}
- public async Task<ItemImageInfo> ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex)
+ public async Task<ItemImageInfo> ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex, bool removeOnFailure)
{
foreach (var url in image.Path.Split('|'))
{
@@ -2793,6 +2806,7 @@ namespace Emby.Server.Implementations.Library
if (ex.StatusCode.HasValue
&& (ex.StatusCode.Value == HttpStatusCode.NotFound || ex.StatusCode.Value == HttpStatusCode.Forbidden))
{
+ _logger.LogDebug(ex, "Error downloading image {Url}", url);
continue;
}
@@ -2800,11 +2814,14 @@ namespace Emby.Server.Implementations.Library
}
}
- // Remove this image to prevent it from retrying over and over
- item.RemoveImage(image);
- await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+ if (removeOnFailure)
+ {
+ // Remove this image to prevent it from retrying over and over
+ item.RemoveImage(image);
+ await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+ }
- throw new InvalidOperationException();
+ throw new InvalidOperationException("Unable to convert any images to local");
}
public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary)
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/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json
index 3f4dea523..a28352219 100644
--- a/Emby.Server.Implementations/Localization/Core/hi.json
+++ b/Emby.Server.Implementations/Localization/Core/hi.json
@@ -4,27 +4,27 @@
"HeaderNextUp": "इसके बाद",
"HeaderLiveTV": "लाइव टीवी",
"HeaderFavoriteSongs": "पसंदीदा गीत",
- "HeaderFavoriteShows": "पसंदीदा शोज",
- "HeaderFavoriteEpisodes": "पसंदीदा एपिसोड्स",
- "HeaderFavoriteArtists": "पसंदीदा कलाकारसमूह",
+ "HeaderFavoriteShows": "पसंदीदा शो",
+ "HeaderFavoriteEpisodes": "पसंदीदा प्रकरण",
+ "HeaderFavoriteArtists": "पसंदीदा कलाकार",
"HeaderFavoriteAlbums": "पसंदीदा एलबम्स",
- "HeaderContinueWatching": "देखते रहिए",
+ "HeaderContinueWatching": "देखना जारी रखें",
"HeaderAlbumArtists": "एल्बम कलाकार",
- "Genres": "शैली",
+ "Genres": "शैलियां",
"Forced": "बलपूर्वक",
- "Folders": "फ़ोल्डरें",
+ "Folders": "फ़ोल्डर",
"Favorites": "पसंदीदा",
"FailedLoginAttemptWithUserName": "{0} से लॉगिन असफल हुआ",
- "DeviceOnlineWithName": "{0} से संयोग हो गया है",
- "DeviceOfflineWithName": "{0} से संयोग विच्छिन्न हो गया है",
+ "DeviceOnlineWithName": "{0} कनेक्ट हो गया है",
+ "DeviceOfflineWithName": "{0} डिस्कनेक्ट हो गया है",
"Default": "प्राथमिक",
- "Collections": "संग्रहों",
- "ChapterNameValue": "अध्याय",
+ "Collections": "संग्रह",
+ "ChapterNameValue": "अध्याय {0}",
"Channels": "चैनल",
- "CameraImageUploadedFrom": "{0} से एक नया कैमरावाला चित्र अपलोड किया गया है",
- "Books": "पुस्तकों",
- "AuthenticationSucceededWithUserName": "सफलता से प्रमाणीकृत",
- "Artists": "कलाकारों",
+ "CameraImageUploadedFrom": "{0} से एक नया कैमरा छवि अपलोड की गई है",
+ "Books": "पुस्तकें",
+ "AuthenticationSucceededWithUserName": "{0} सफलतापूर्वक प्रमाणित किया गया",
+ "Artists": "कलाकार",
"Application": "एप्लिकेशन",
"AppDeviceValues": "एप: {0}, उपकरण: {1}",
"NotificationOptionPluginUninstalled": "प्लगइन अनइंस्टाल हो गया",
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/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs
index c031ce338..6b38fa7d3 100644
--- a/Jellyfin.Api/Controllers/ImageController.cs
+++ b/Jellyfin.Api/Controllers/ImageController.cs
@@ -11,8 +11,9 @@ using System.Security.Cryptography;
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.Extensions;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
@@ -87,31 +88,26 @@ public class ImageController : BaseJellyfinApiController
/// Sets the user image.
/// </summary>
/// <param name="userId">User Id.</param>
- /// <param name="imageType">(Unused) Image type.</param>
- /// <param name="index">(Unused) Image index.</param>
/// <response code="204">Image updated.</response>
/// <response code="403">User does not have permission to delete the image.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
- [HttpPost("Users/{userId}/Images/{imageType}")]
+ [HttpPost("UserImage")]
[Authorize]
[AcceptsImageFile]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> PostUserImage(
- [FromRoute, Required] Guid userId,
- [FromRoute, Required] ImageType imageType,
- [FromQuery] int? index = null)
+ [FromQuery] Guid? userId)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
}
- if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+ if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, requestUserId, true))
{
return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
}
@@ -148,87 +144,69 @@ public class ImageController : BaseJellyfinApiController
/// </summary>
/// <param name="userId">User Id.</param>
/// <param name="imageType">(Unused) Image type.</param>
+ /// <response code="204">Image updated.</response>
+ /// <response code="403">User does not have permission to delete the image.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("Users/{userId}/Images/{imageType}")]
+ [Authorize]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ [AcceptsImageFile]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+ public Task<ActionResult> PostUserImageLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] ImageType imageType)
+ => PostUserImage(userId);
+
+ /// <summary>
+ /// Sets the user image.
+ /// </summary>
+ /// <param name="userId">User Id.</param>
+ /// <param name="imageType">(Unused) Image type.</param>
/// <param name="index">(Unused) Image index.</param>
/// <response code="204">Image updated.</response>
/// <response code="403">User does not have permission to delete the image.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Users/{userId}/Images/{imageType}/{index}")]
[Authorize]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
[AcceptsImageFile]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
- public async Task<ActionResult> PostUserImageByIndex(
+ public Task<ActionResult> PostUserImageByIndexLegacy(
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
[FromRoute] int index)
- {
- var user = _userManager.GetUserById(userId);
- if (user is null)
- {
- return NotFound();
- }
-
- if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
- {
- return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
- }
-
- if (!TryGetImageExtensionFromContentType(Request.ContentType, out string? extension))
- {
- return BadRequest("Incorrect ContentType.");
- }
-
- var stream = GetFromBase64Stream(Request.Body);
- await using (stream.ConfigureAwait(false))
- {
- // Handle image/png; charset=utf-8
- var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
- var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
- if (user.ProfileImage is not null)
- {
- await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
- }
-
- user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension));
-
- await _providerManager
- .SaveImage(stream, mimeType, user.ProfileImage.Path)
- .ConfigureAwait(false);
- await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
-
- return NoContent();
- }
- }
+ => PostUserImage(userId);
/// <summary>
/// Delete the user's image.
/// </summary>
/// <param name="userId">User Id.</param>
- /// <param name="imageType">(Unused) Image type.</param>
- /// <param name="index">(Unused) Image index.</param>
/// <response code="204">Image deleted.</response>
/// <response code="403">User does not have permission to delete the image.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
- [HttpDelete("Users/{userId}/Images/{imageType}")]
+ [HttpDelete("UserImage")]
[Authorize]
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> DeleteUserImage(
- [FromRoute, Required] Guid userId,
- [FromRoute, Required] ImageType imageType,
- [FromQuery] int? index = null)
+ [FromQuery] Guid? userId)
{
- if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, requestUserId, true))
{
return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
}
- var user = _userManager.GetUserById(userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user?.ProfileImage is null)
{
return NoContent();
@@ -256,40 +234,42 @@ public class ImageController : BaseJellyfinApiController
/// <response code="204">Image deleted.</response>
/// <response code="403">User does not have permission to delete the image.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("Users/{userId}/Images/{imageType}")]
+ [Authorize]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public Task<ActionResult> DeleteUserImageLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] ImageType imageType,
+ [FromQuery] int? index = null)
+ => DeleteUserImage(userId);
+
+ /// <summary>
+ /// Delete the user's image.
+ /// </summary>
+ /// <param name="userId">User Id.</param>
+ /// <param name="imageType">(Unused) Image type.</param>
+ /// <param name="index">(Unused) Image index.</param>
+ /// <response code="204">Image deleted.</response>
+ /// <response code="403">User does not have permission to delete the image.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpDelete("Users/{userId}/Images/{imageType}/{index}")]
[Authorize]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
- public async Task<ActionResult> DeleteUserImageByIndex(
+ public Task<ActionResult> DeleteUserImageByIndexLegacy(
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
[FromRoute] int index)
- {
- if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
- {
- return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
- }
-
- var user = _userManager.GetUserById(userId);
- if (user?.ProfileImage is null)
- {
- return NoContent();
- }
-
- try
- {
- System.IO.File.Delete(user.ProfileImage.Path);
- }
- catch (IOException e)
- {
- _logger.LogError(e, "Error deleting user profile image:");
- }
-
- await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
- return NoContent();
- }
+ => DeleteUserImage(userId);
/// <summary>
/// Delete an item's image.
@@ -542,7 +522,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
/// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
/// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
@@ -572,7 +551,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
[FromQuery] string? tag,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] ImageFormat? format,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
@@ -623,7 +601,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
/// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
/// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
@@ -653,7 +630,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
[FromQuery] string? tag,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] ImageFormat? format,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
@@ -702,7 +678,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
/// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
/// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
@@ -732,7 +707,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
[FromRoute, Required] string tag,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromRoute, Required] ImageFormat format,
[FromRoute, Required] double percentPlayed,
[FromRoute, Required] int unplayedCount,
@@ -785,7 +759,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -815,7 +788,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? quality,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
@@ -865,7 +837,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -895,7 +866,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? quality,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
@@ -946,7 +916,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -976,7 +945,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? quality,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
@@ -1025,7 +993,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1055,7 +1022,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? quality,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
@@ -1106,7 +1072,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1136,7 +1101,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? quality,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
@@ -1185,7 +1149,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1215,7 +1178,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? quality,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
@@ -1266,7 +1228,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1296,7 +1257,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? quality,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
@@ -1345,7 +1305,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1375,7 +1334,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? quality,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
@@ -1426,7 +1384,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1456,7 +1413,6 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? quality,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
@@ -1493,7 +1449,6 @@ public class ImageController : BaseJellyfinApiController
/// Get user profile image.
/// </summary>
/// <param name="userId">User id.</param>
- /// <param name="imageType">Image type.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
/// <param name="maxWidth">The maximum image width to return.</param>
@@ -1505,25 +1460,25 @@ public class ImageController : BaseJellyfinApiController
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
/// <param name="imageIndex">Image index.</param>
/// <response code="200">Image stream returned.</response>
+ /// <response code="400">User id not provided.</response>
/// <response code="404">Item not found.</response>
/// <returns>
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
- [HttpGet("Users/{userId}/Images/{imageType}")]
- [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")]
+ [HttpGet("UserImage")]
+ [HttpHead("UserImage", Name = "HeadUserImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetUserImage(
- [FromRoute, Required] Guid userId,
- [FromRoute, Required] ImageType imageType,
+ [FromQuery] Guid? userId,
[FromQuery] string? tag,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
@@ -1535,13 +1490,18 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? quality,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
[FromQuery] int? imageIndex)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = userId ?? User.GetUserId();
+ if (requestUserId.IsEmpty())
+ {
+ return BadRequest("UserId is required if unauthenticated");
+ }
+
+ var user = _userManager.GetUserById(requestUserId);
if (user?.ProfileImage is null)
{
return NotFound();
@@ -1566,7 +1526,7 @@ public class ImageController : BaseJellyfinApiController
return await GetImageInternal(
user.Id,
- imageType,
+ ImageType.Profile,
imageIndex,
tag,
format,
@@ -1592,6 +1552,75 @@ public class ImageController : BaseJellyfinApiController
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="imageType">Image type.</param>
+ /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+ /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+ /// <param name="maxWidth">The maximum image width to return.</param>
+ /// <param name="maxHeight">The maximum image height to return.</param>
+ /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+ /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+ /// <param name="width">The fixed image width to return.</param>
+ /// <param name="height">The fixed image height to return.</param>
+ /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+ /// <param name="fillWidth">Width of box to fill.</param>
+ /// <param name="fillHeight">Height of box to fill.</param>
+ /// <param name="blur">Optional. Blur image.</param>
+ /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+ /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+ /// <param name="imageIndex">Image index.</param>
+ /// <response code="200">Image stream returned.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>
+ /// A <see cref="FileStreamResult"/> containing the file stream on success,
+ /// or a <see cref="NotFoundResult"/> if item not found.
+ /// </returns>
+ [HttpGet("Users/{userId}/Images/{imageType}")]
+ [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImageLegacy")]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public Task<ActionResult> GetUserImageLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] ImageType imageType,
+ [FromQuery] string? tag,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] int? fillWidth,
+ [FromQuery] int? fillHeight,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer,
+ [FromQuery] int? imageIndex)
+ => GetUserImage(
+ userId,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ fillWidth,
+ fillHeight,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ imageIndex);
+
+ /// <summary>
+ /// Get user profile image.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="imageType">Image type.</param>
/// <param name="imageIndex">Image index.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
@@ -1604,7 +1633,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="fillWidth">Width of box to fill.</param>
/// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1615,11 +1643,13 @@ public class ImageController : BaseJellyfinApiController
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
[HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")]
- [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")]
+ [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndexLegacy")]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
- public async Task<ActionResult> GetUserImageByIndex(
+ public Task<ActionResult> GetUserImageByIndexLegacy(
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
[FromRoute, Required] int imageIndex,
@@ -1634,56 +1664,26 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] int? quality,
[FromQuery] int? fillWidth,
[FromQuery] int? fillHeight,
- [FromQuery, ParameterObsolete] bool? cropWhitespace,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
- {
- var user = _userManager.GetUserById(userId);
- if (user?.ProfileImage is null)
- {
- return NotFound();
- }
-
- var info = new ItemImageInfo
- {
- Path = user.ProfileImage.Path,
- Type = ImageType.Profile,
- DateModified = user.ProfileImage.LastModified
- };
-
- if (width.HasValue)
- {
- info.Width = width.Value;
- }
-
- if (height.HasValue)
- {
- info.Height = height.Value;
- }
-
- return await GetImageInternal(
- user.Id,
- imageType,
- imageIndex,
- tag,
- format,
- maxWidth,
- maxHeight,
- percentPlayed,
- unplayedCount,
- width,
- height,
- quality,
- fillWidth,
- fillHeight,
- blur,
- backgroundColor,
- foregroundLayer,
- null,
- info)
- .ConfigureAwait(false);
- }
+ => GetUserImage(
+ userId,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ fillWidth,
+ fillHeight,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ imageIndex);
/// <summary>
/// Generates or gets the splashscreen.
@@ -1993,7 +1993,7 @@ public class ImageController : BaseJellyfinApiController
{
if (format.HasValue)
{
- return new[] { format.Value };
+ return [format.Value];
}
return GetClientSupportedFormats();
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/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index d10fba920..26ae1a820 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -612,8 +612,10 @@ public class ItemsController : BaseJellyfinApiController
/// <param name="enableImages">Optional, include image information in output.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
[HttpGet("Users/{userId}/Items")]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId(
+ public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserIdLegacy(
[FromRoute] Guid userId,
[FromQuery] string? maxOfficialRating,
[FromQuery] bool? hasThemeSong,
@@ -699,8 +701,7 @@ public class ItemsController : BaseJellyfinApiController
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true)
- {
- return GetItems(
+ => GetItems(
userId,
maxOfficialRating,
hasThemeSong,
@@ -786,7 +787,6 @@ public class ItemsController : BaseJellyfinApiController
genreIds,
enableTotalRecordCount,
enableImages);
- }
/// <summary>
/// Gets items based on a query.
@@ -808,10 +808,10 @@ public class ItemsController : BaseJellyfinApiController
/// <param name="excludeActiveSessions">Optional. Whether to exclude the currently active sessions.</param>
/// <response code="200">Items returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns>
- [HttpGet("Users/{userId}/Items/Resume")]
+ [HttpGet("UserItems/Resume")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetResumeItems(
- [FromRoute, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
@@ -827,7 +827,8 @@ public class ItemsController : BaseJellyfinApiController
[FromQuery] bool? enableImages = true,
[FromQuery] bool excludeActiveSessions = false)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -854,7 +855,7 @@ public class ItemsController : BaseJellyfinApiController
if (excludeActiveSessions)
{
excludeItemIds = _sessionManager.Sessions
- .Where(s => s.UserId.Equals(userId) && s.NowPlayingItem is not null)
+ .Where(s => s.UserId.Equals(requestUserId) && s.NowPlayingItem is not null)
.Select(s => s.NowPlayingItem.Id)
.ToArray();
}
@@ -888,6 +889,63 @@ public class ItemsController : BaseJellyfinApiController
}
/// <summary>
+ /// Gets items based on a query.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="startIndex">The start index.</param>
+ /// <param name="limit">The item limit.</param>
+ /// <param name="searchTerm">The search term.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+ /// <param name="enableUserData">Optional. Include user data.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
+ /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="excludeActiveSessions">Optional. Whether to exclude the currently active sessions.</param>
+ /// <response code="200">Items returned.</response>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns>
+ [HttpGet("Users/{userId}/Items/Resume")]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetResumeItemsLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] string? searchTerm,
+ [FromQuery] Guid? parentId,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+ [FromQuery] bool enableTotalRecordCount = true,
+ [FromQuery] bool? enableImages = true,
+ [FromQuery] bool excludeActiveSessions = false)
+ => GetResumeItems(
+ userId,
+ startIndex,
+ limit,
+ searchTerm,
+ parentId,
+ fields,
+ mediaTypes,
+ enableUserData,
+ imageTypeLimit,
+ enableImageTypes,
+ excludeItemTypes,
+ includeItemTypes,
+ enableTotalRecordCount,
+ enableImages,
+ excludeActiveSessions);
+
+ /// <summary>
/// Get Item User Data.
/// </summary>
/// <param name="userId">The user id.</param>
@@ -895,25 +953,44 @@ public class ItemsController : BaseJellyfinApiController
/// <response code="200">return item user data.</response>
/// <response code="404">Item is not found.</response>
/// <returns>Return <see cref="UserItemDataDto"/>.</returns>
- [HttpGet("Users/{userId}/Items/{itemId}/UserData")]
+ [HttpGet("UserItems/{itemId}/UserData")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<UserItemDataDto> GetItemUserData(
- [FromRoute, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
- if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, requestUserId, true))
{
return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to view this item user data.");
}
- var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException();
+ var user = _userManager.GetUserById(requestUserId) ?? throw new ResourceNotFoundException();
var item = _libraryManager.GetItemById(itemId);
return (item == null) ? NotFound() : _userDataRepository.GetUserDataDto(item, user);
}
/// <summary>
+ /// Get Item User Data.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <response code="200">return item user data.</response>
+ /// <response code="404">Item is not found.</response>
+ /// <returns>Return <see cref="UserItemDataDto"/>.</returns>
+ [HttpGet("Users/{userId}/Items/{itemId}/UserData")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<UserItemDataDto> GetItemUserDataLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId)
+ => GetItemUserData(userId, itemId);
+
+ /// <summary>
/// Update Item User Data.
/// </summary>
/// <param name="userId">The user id.</param>
@@ -922,20 +999,21 @@ public class ItemsController : BaseJellyfinApiController
/// <response code="200">return updated user item data.</response>
/// <response code="404">Item is not found.</response>
/// <returns>Return <see cref="UserItemDataDto"/>.</returns>
- [HttpPost("Users/{userId}/Items/{itemId}/UserData")]
+ [HttpPost("UserItems/{itemId}/UserData")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<UserItemDataDto> UpdateItemUserData(
- [FromRoute, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId,
[FromBody, Required] UpdateUserItemDataDto userDataDto)
{
- if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, requestUserId, true))
{
return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update this item user data.");
}
- var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException();
+ var user = _userManager.GetUserById(requestUserId) ?? throw new ResourceNotFoundException();
var item = _libraryManager.GetItemById(itemId);
if (item == null)
{
@@ -946,4 +1024,24 @@ public class ItemsController : BaseJellyfinApiController
return _userDataRepository.GetUserDataDto(item, user);
}
+
+ /// <summary>
+ /// Update Item User Data.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="userDataDto">New user data object.</param>
+ /// <response code="200">return updated user item data.</response>
+ /// <response code="404">Item is not found.</response>
+ /// <returns>Return <see cref="UserItemDataDto"/>.</returns>
+ [HttpPost("Users/{userId}/Items/{itemId}/UserData")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<UserItemDataDto> UpdateItemUserDataLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId,
+ [FromBody, Required] UpdateUserItemDataDto userDataDto)
+ => UpdateItemUserData(userId, itemId, userDataDto);
}
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index e357588d1..984dc7789 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -913,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/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs
index da68c72c9..7768b3c45 100644
--- a/Jellyfin.Api/Controllers/LiveTvController.cs
+++ b/Jellyfin.Api/Controllers/LiveTvController.cs
@@ -45,6 +45,8 @@ 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;
@@ -59,6 +61,8 @@ public class LiveTvController : BaseJellyfinApiController
/// <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>
@@ -70,6 +74,8 @@ public class LiveTvController : BaseJellyfinApiController
ILiveTvManager liveTvManager,
IGuideManager guideManager,
ITunerHostManager tunerHostManager,
+ IListingsManager listingsManager,
+ IRecordingsManager recordingsManager,
IUserManager userManager,
IHttpClientFactory httpClientFactory,
ILibraryManager libraryManager,
@@ -81,6 +87,8 @@ public class LiveTvController : BaseJellyfinApiController
_liveTvManager = liveTvManager;
_guideManager = guideManager;
_tunerHostManager = tunerHostManager;
+ _listingsManager = listingsManager;
+ _recordingsManager = recordingsManager;
_userManager = userManager;
_httpClientFactory = httpClientFactory;
_libraryManager = libraryManager;
@@ -628,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)
{
@@ -1015,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>
@@ -1029,7 +1037,7 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DeleteListingProvider([FromQuery] string? id)
{
- _liveTvManager.DeleteListingsProvider(id);
+ _listingsManager.DeleteListingsProvider(id);
return NoContent();
}
@@ -1050,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.
@@ -1083,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.
@@ -1166,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..f2b312b47
--- /dev/null
+++ b/Jellyfin.Api/Controllers/LyricsController.cs
@@ -0,0 +1,265 @@
+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.SaveLyricAsync(
+ audio,
+ format,
+ 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/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs
index bde2f4d1a..949d101dc 100644
--- a/Jellyfin.Api/Controllers/PlaystateController.cs
+++ b/Jellyfin.Api/Controllers/PlaystateController.cs
@@ -68,15 +68,16 @@ public class PlaystateController : BaseJellyfinApiController
/// <response code="200">Item marked as played.</response>
/// <response code="404">Item not found.</response>
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
- [HttpPost("Users/{userId}/PlayedItems/{itemId}")]
+ [HttpPost("UserPlayedItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserItemDataDto>> MarkPlayedItem(
- [FromRoute, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId,
[FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -106,6 +107,26 @@ public class PlaystateController : BaseJellyfinApiController
}
/// <summary>
+ /// Marks an item as played for user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="datePlayed">Optional. The date the item was played.</param>
+ /// <response code="200">Item marked as played.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
+ [HttpPost("Users/{userId}/PlayedItems/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public Task<ActionResult<UserItemDataDto>> MarkPlayedItemLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId,
+ [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
+ => MarkPlayedItem(userId, itemId, datePlayed);
+
+ /// <summary>
/// Marks an item as unplayed for user.
/// </summary>
/// <param name="userId">User id.</param>
@@ -113,12 +134,15 @@ public class PlaystateController : BaseJellyfinApiController
/// <response code="200">Item marked as unplayed.</response>
/// <response code="404">Item not found.</response>
/// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
- [HttpDelete("Users/{userId}/PlayedItems/{itemId}")]
+ [HttpDelete("UserPlayedItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task<ActionResult<UserItemDataDto>> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+ public async Task<ActionResult<UserItemDataDto>> MarkUnplayedItem(
+ [FromQuery] Guid? userId,
+ [FromRoute, Required] Guid itemId)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -148,6 +172,24 @@ public class PlaystateController : BaseJellyfinApiController
}
/// <summary>
+ /// Marks an item as unplayed for user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Item marked as unplayed.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
+ [HttpDelete("Users/{userId}/PlayedItems/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public Task<ActionResult<UserItemDataDto>> MarkUnplayedItemLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId)
+ => MarkUnplayedItem(userId, itemId);
+
+ /// <summary>
/// Reports playback has started within a session.
/// </summary>
/// <param name="playbackStartInfo">The playback start info.</param>
@@ -215,9 +257,8 @@ public class PlaystateController : BaseJellyfinApiController
}
/// <summary>
- /// Reports that a user has begun playing an item.
+ /// Reports that a session has begun playing an item.
/// </summary>
- /// <param name="userId">User id.</param>
/// <param name="itemId">Item id.</param>
/// <param name="mediaSourceId">The id of the MediaSource.</param>
/// <param name="audioStreamIndex">The audio stream index.</param>
@@ -228,11 +269,9 @@ public class PlaystateController : BaseJellyfinApiController
/// <param name="canSeek">Indicates if the client can seek.</param>
/// <response code="204">Play start recorded.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
- [HttpPost("Users/{userId}/PlayingItems/{itemId}")]
+ [HttpPost("PlayingItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
public async Task<ActionResult> OnPlaybackStart(
- [FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId,
[FromQuery] string? mediaSourceId,
[FromQuery] int? audioStreamIndex,
@@ -261,11 +300,41 @@ public class PlaystateController : BaseJellyfinApiController
}
/// <summary>
- /// Reports a user's playback progress.
+ /// Reports that a user has begun playing an item.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="itemId">Item id.</param>
/// <param name="mediaSourceId">The id of the MediaSource.</param>
+ /// <param name="audioStreamIndex">The audio stream index.</param>
+ /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+ /// <param name="playMethod">The play method.</param>
+ /// <param name="liveStreamId">The live stream id.</param>
+ /// <param name="playSessionId">The play session id.</param>
+ /// <param name="canSeek">Indicates if the client can seek.</param>
+ /// <response code="204">Play start recorded.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("Users/{userId}/PlayingItems/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+ public Task<ActionResult> OnPlaybackStartLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId,
+ [FromQuery] string? mediaSourceId,
+ [FromQuery] int? audioStreamIndex,
+ [FromQuery] int? subtitleStreamIndex,
+ [FromQuery] PlayMethod? playMethod,
+ [FromQuery] string? liveStreamId,
+ [FromQuery] string? playSessionId,
+ [FromQuery] bool canSeek = false)
+ => OnPlaybackStart(itemId, mediaSourceId, audioStreamIndex, subtitleStreamIndex, playMethod, liveStreamId, playSessionId, canSeek);
+
+ /// <summary>
+ /// Reports a session's playback progress.
+ /// </summary>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="mediaSourceId">The id of the MediaSource.</param>
/// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param>
/// <param name="audioStreamIndex">The audio stream index.</param>
/// <param name="subtitleStreamIndex">The subtitle stream index.</param>
@@ -278,11 +347,9 @@ public class PlaystateController : BaseJellyfinApiController
/// <param name="isMuted">Indicates if the player is muted.</param>
/// <response code="204">Play progress recorded.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
- [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")]
+ [HttpPost("PlayingItems/{itemId}/Progress")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
public async Task<ActionResult> OnPlaybackProgress(
- [FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId,
[FromQuery] string? mediaSourceId,
[FromQuery] long? positionTicks,
@@ -319,22 +386,58 @@ public class PlaystateController : BaseJellyfinApiController
}
/// <summary>
- /// Reports that a user has stopped playing an item.
+ /// Reports a user's playback progress.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="itemId">Item id.</param>
/// <param name="mediaSourceId">The id of the MediaSource.</param>
+ /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param>
+ /// <param name="audioStreamIndex">The audio stream index.</param>
+ /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+ /// <param name="volumeLevel">Scale of 0-100.</param>
+ /// <param name="playMethod">The play method.</param>
+ /// <param name="liveStreamId">The live stream id.</param>
+ /// <param name="playSessionId">The play session id.</param>
+ /// <param name="repeatMode">The repeat mode.</param>
+ /// <param name="isPaused">Indicates if the player is paused.</param>
+ /// <param name="isMuted">Indicates if the player is muted.</param>
+ /// <response code="204">Play progress recorded.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+ public Task<ActionResult> OnPlaybackProgressLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId,
+ [FromQuery] string? mediaSourceId,
+ [FromQuery] long? positionTicks,
+ [FromQuery] int? audioStreamIndex,
+ [FromQuery] int? subtitleStreamIndex,
+ [FromQuery] int? volumeLevel,
+ [FromQuery] PlayMethod? playMethod,
+ [FromQuery] string? liveStreamId,
+ [FromQuery] string? playSessionId,
+ [FromQuery] RepeatMode? repeatMode,
+ [FromQuery] bool isPaused = false,
+ [FromQuery] bool isMuted = false)
+ => OnPlaybackProgress(itemId, mediaSourceId, positionTicks, audioStreamIndex, subtitleStreamIndex, volumeLevel, playMethod, liveStreamId, playSessionId, repeatMode, isPaused, isMuted);
+
+ /// <summary>
+ /// Reports that a session has stopped playing an item.
+ /// </summary>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="mediaSourceId">The id of the MediaSource.</param>
/// <param name="nextMediaType">The next media type that will play.</param>
/// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param>
/// <param name="liveStreamId">The live stream id.</param>
/// <param name="playSessionId">The play session id.</param>
/// <response code="204">Playback stop recorded.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
- [HttpDelete("Users/{userId}/PlayingItems/{itemId}")]
+ [HttpDelete("PlayingItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
public async Task<ActionResult> OnPlaybackStopped(
- [FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId,
[FromQuery] string? mediaSourceId,
[FromQuery] string? nextMediaType,
@@ -364,6 +467,33 @@ public class PlaystateController : BaseJellyfinApiController
}
/// <summary>
+ /// Reports that a user has stopped playing an item.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="mediaSourceId">The id of the MediaSource.</param>
+ /// <param name="nextMediaType">The next media type that will play.</param>
+ /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param>
+ /// <param name="liveStreamId">The live stream id.</param>
+ /// <param name="playSessionId">The play session id.</param>
+ /// <response code="204">Playback stop recorded.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("Users/{userId}/PlayingItems/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+ public Task<ActionResult> OnPlaybackStoppedLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId,
+ [FromQuery] string? mediaSourceId,
+ [FromQuery] string? nextMediaType,
+ [FromQuery] long? positionTicks,
+ [FromQuery] string? liveStreamId,
+ [FromQuery] string? playSessionId)
+ => OnPlaybackStopped(itemId, mediaSourceId, nextMediaType, positionTicks, liveStreamId, playSessionId);
+
+ /// <summary>
/// Updates the played status.
/// </summary>
/// <param name="user">The user.</param>
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/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs
index 2aa6d25a7..ad625cc6e 100644
--- a/Jellyfin.Api/Controllers/SuggestionsController.cs
+++ b/Jellyfin.Api/Controllers/SuggestionsController.cs
@@ -1,7 +1,9 @@
using System;
using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
+using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Dto;
@@ -53,19 +55,26 @@ public class SuggestionsController : BaseJellyfinApiController
/// <param name="enableTotalRecordCount">Whether to enable the total record count.</param>
/// <response code="200">Suggestions returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns>
- [HttpGet("Users/{userId}/Suggestions")]
+ [HttpGet("Items/Suggestions")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
- [FromRoute, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaType,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] bool enableTotalRecordCount = false)
{
- var user = userId.IsEmpty()
- ? null
- : _userManager.GetUserById(userId);
+ User? user;
+ if (userId.IsNullOrEmpty())
+ {
+ user = null;
+ }
+ else
+ {
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ user = _userManager.GetUserById(requestUserId);
+ }
var dtoOptions = new DtoOptions().AddClientFields(User);
var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
@@ -88,4 +97,28 @@ public class SuggestionsController : BaseJellyfinApiController
result.TotalRecordCount,
dtoList);
}
+
+ /// <summary>
+ /// Gets suggestions.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="mediaType">The media types.</param>
+ /// <param name="type">The type.</param>
+ /// <param name="startIndex">Optional. The start index.</param>
+ /// <param name="limit">Optional. The limit.</param>
+ /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param>
+ /// <response code="200">Suggestions returned.</response>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns>
+ [HttpGet("Users/{userId}/Suggestions")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<QueryResult<BaseItemDto>> GetSuggestionsLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaType,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] bool enableTotalRecordCount = false)
+ => GetSuggestions(userId, mediaType, type, startIndex, limit, enableTotalRecordCount);
}
diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs
index 0a416aedb..4c3ef2c7f 100644
--- a/Jellyfin.Api/Controllers/UniversalAudioController.cs
+++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs
@@ -8,6 +8,7 @@ using Jellyfin.Api.Attributes;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.StreamingDtos;
+using Jellyfin.Data.Enums;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
@@ -98,7 +99,7 @@ public class UniversalAudioController : BaseJellyfinApiController
[FromQuery] int? audioBitRate,
[FromQuery] long? startTimeTicks,
[FromQuery] string? transcodingContainer,
- [FromQuery] string? transcodingProtocol,
+ [FromQuery] MediaStreamProtocol? transcodingProtocol,
[FromQuery] int? maxAudioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] bool? enableRemoteMedia,
@@ -156,7 +157,7 @@ public class UniversalAudioController : BaseJellyfinApiController
}
var isStatic = mediaSource.SupportsDirectStream;
- if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
+ if (!isStatic && mediaSource.TranscodingSubProtocol == MediaStreamProtocol.Hls)
{
// hls segment container can only be mpegts or fmp4 per ffmpeg documentation
// ffmpeg option -> file extension
@@ -232,7 +233,7 @@ public class UniversalAudioController : BaseJellyfinApiController
string[] containers,
string? transcodingContainer,
string? audioCodec,
- string? transcodingProtocol,
+ MediaStreamProtocol? transcodingProtocol,
bool? breakOnNonKeyFrames,
int? transcodingAudioChannels,
int? maxAudioSampleRate,
@@ -267,7 +268,7 @@ public class UniversalAudioController : BaseJellyfinApiController
Context = EncodingContext.Streaming,
Container = transcodingContainer ?? "mp3",
AudioCodec = audioCodec ?? "mp3",
- Protocol = transcodingProtocol ?? "http",
+ Protocol = transcodingProtocol ?? MediaStreamProtocol.Http,
BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture)
}
diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs
index ea10ee24f..c3923a2ad 100644
--- a/Jellyfin.Api/Controllers/UserController.cs
+++ b/Jellyfin.Api/Controllers/UserController.cs
@@ -178,6 +178,7 @@ public class UserController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ApiExplorerSettings(IgnoreApi = true)]
[Obsolete("Authenticate with username instead")]
public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
[FromRoute, Required] Guid userId,
@@ -263,21 +264,22 @@ public class UserController : BaseJellyfinApiController
/// <response code="403">User is not allowed to update the password.</response>
/// <response code="404">User not found.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
- [HttpPost("{userId}/Password")]
+ [HttpPost("Password")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdateUserPassword(
- [FromRoute, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromBody, Required] UpdateUserPassword request)
{
- if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+ var requestUserId = userId ?? User.GetUserId();
+ if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, requestUserId, true))
{
return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password.");
}
- var user = _userManager.GetUserById(userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
@@ -290,7 +292,7 @@ public class UserController : BaseJellyfinApiController
}
else
{
- if (!User.IsInRole(UserRoles.Administrator) || User.GetUserId().Equals(userId))
+ if (!User.IsInRole(UserRoles.Administrator) || (userId.HasValue && User.GetUserId().Equals(userId.Value)))
{
var success = await _userManager.AuthenticateUser(
user.Username,
@@ -316,6 +318,27 @@ public class UserController : BaseJellyfinApiController
}
/// <summary>
+ /// Updates a user's password.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param>
+ /// <response code="204">Password successfully reset.</response>
+ /// <response code="403">User is not allowed to update the password.</response>
+ /// <response code="404">User not found.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
+ [HttpPost("{userId}/Password")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public Task<ActionResult> UpdateUserPasswordLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromBody, Required] UpdateUserPassword request)
+ => UpdateUserPassword(userId, request);
+
+ /// <summary>
/// Updates a user's easy password.
/// </summary>
/// <param name="userId">The user id.</param>
@@ -326,6 +349,7 @@ public class UserController : BaseJellyfinApiController
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
[HttpPost("{userId}/EasyPassword")]
[Obsolete("Use Quick Connect instead")]
+ [ApiExplorerSettings(IgnoreApi = true)]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
@@ -346,22 +370,23 @@ public class UserController : BaseJellyfinApiController
/// <response code="400">User information was not supplied.</response>
/// <response code="403">User update forbidden.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns>
- [HttpPost("{userId}")]
+ [HttpPost]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UpdateUser(
- [FromRoute, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromBody, Required] UserDto updateUser)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = userId ?? User.GetUserId();
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
}
- if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+ if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, requestUserId, true))
{
return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed.");
}
@@ -377,6 +402,27 @@ public class UserController : BaseJellyfinApiController
}
/// <summary>
+ /// Updates a user.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="updateUser">The updated user model.</param>
+ /// <response code="204">User updated.</response>
+ /// <response code="400">User information was not supplied.</response>
+ /// <response code="403">User update forbidden.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns>
+ [HttpPost("{userId}")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public Task<ActionResult> UpdateUserLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromBody, Required] UserDto updateUser)
+ => UpdateUser(userId, updateUser);
+
+ /// <summary>
/// Updates a user policy.
/// </summary>
/// <param name="userId">The user id.</param>
@@ -440,25 +486,45 @@ public class UserController : BaseJellyfinApiController
/// <response code="204">User configuration updated.</response>
/// <response code="403">User configuration update forbidden.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
- [HttpPost("{userId}/Configuration")]
+ [HttpPost("Configuration")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UpdateUserConfiguration(
- [FromRoute, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromBody, Required] UserConfiguration userConfig)
{
- if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+ var requestUserId = userId ?? User.GetUserId();
+ if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, requestUserId, true))
{
return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed");
}
- await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false);
+ await _userManager.UpdateConfigurationAsync(requestUserId, userConfig).ConfigureAwait(false);
return NoContent();
}
/// <summary>
+ /// Updates a user configuration.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="userConfig">The new user configuration.</param>
+ /// <response code="204">User configuration updated.</response>
+ /// <response code="403">User configuration update forbidden.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("{userId}/Configuration")]
+ [Authorize]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public Task<ActionResult> UpdateUserConfigurationLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromBody, Required] UserConfiguration userConfig)
+ => UpdateUserConfiguration(userId, userConfig);
+
+ /// <summary>
/// Creates a user.
/// </summary>
/// <param name="request">The create user by name request body.</param>
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index 264e0a3db..c19ad33c8 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
@@ -13,7 +14,6 @@ using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
@@ -38,7 +38,6 @@ public class UserLibraryController : BaseJellyfinApiController
private readonly IDtoService _dtoService;
private readonly IUserViewManager _userViewManager;
private readonly IFileSystem _fileSystem;
- private readonly ILyricManager _lyricManager;
/// <summary>
/// Initializes a new instance of the <see cref="UserLibraryController"/> class.
@@ -49,15 +48,13 @@ public class UserLibraryController : BaseJellyfinApiController
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
/// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
- /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
public UserLibraryController(
IUserManager userManager,
IUserDataManager userDataRepository,
ILibraryManager libraryManager,
IDtoService dtoService,
IUserViewManager userViewManager,
- IFileSystem fileSystem,
- ILyricManager lyricManager)
+ IFileSystem fileSystem)
{
_userManager = userManager;
_userDataRepository = userDataRepository;
@@ -65,7 +62,6 @@ public class UserLibraryController : BaseJellyfinApiController
_dtoService = dtoService;
_userViewManager = userViewManager;
_fileSystem = fileSystem;
- _lyricManager = lyricManager;
}
/// <summary>
@@ -75,11 +71,14 @@ public class UserLibraryController : BaseJellyfinApiController
/// <param name="itemId">Item id.</param>
/// <response code="200">Item returned.</response>
/// <returns>An <see cref="OkResult"/> containing the item.</returns>
- [HttpGet("Users/{userId}/Items/{itemId}")]
+ [HttpGet("Items/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+ public async Task<ActionResult<BaseItemDto>> GetItem(
+ [FromQuery] Guid? userId,
+ [FromRoute, Required] Guid itemId)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -109,16 +108,33 @@ public class UserLibraryController : BaseJellyfinApiController
}
/// <summary>
+ /// Gets an item from a user's library.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Item returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the item.</returns>
+ [HttpGet("Users/{userId}/Items/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public Task<ActionResult<BaseItemDto>> GetItemLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId)
+ => GetItem(userId, itemId);
+
+ /// <summary>
/// Gets the root folder from a user's library.
/// </summary>
/// <param name="userId">User id.</param>
/// <response code="200">Root folder returned.</response>
/// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns>
- [HttpGet("Users/{userId}/Items/Root")]
+ [HttpGet("Items/Root")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<BaseItemDto> GetRootFolder([FromRoute, Required] Guid userId)
+ public ActionResult<BaseItemDto> GetRootFolder([FromQuery] Guid? userId)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -130,17 +146,34 @@ public class UserLibraryController : BaseJellyfinApiController
}
/// <summary>
+ /// Gets the root folder from a user's library.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <response code="200">Root folder returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns>
+ [HttpGet("Users/{userId}/Items/Root")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<BaseItemDto> GetRootFolderLegacy(
+ [FromRoute, Required] Guid userId)
+ => GetRootFolder(userId);
+
+ /// <summary>
/// Gets intros to play before the main media item plays.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="itemId">Item id.</param>
/// <response code="200">Intros returned.</response>
/// <returns>An <see cref="OkResult"/> containing the intros to play.</returns>
- [HttpGet("Users/{userId}/Items/{itemId}/Intros")]
+ [HttpGet("Items/{itemId}/Intros")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+ public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros(
+ [FromQuery] Guid? userId,
+ [FromRoute, Required] Guid itemId)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -170,17 +203,36 @@ public class UserLibraryController : BaseJellyfinApiController
}
/// <summary>
+ /// Gets intros to play before the main media item plays.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Intros returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns>
+ [HttpGet("Users/{userId}/Items/{itemId}/Intros")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public Task<ActionResult<QueryResult<BaseItemDto>>> GetIntrosLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId)
+ => GetIntros(userId, itemId);
+
+ /// <summary>
/// Marks an item as a favorite.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="itemId">Item id.</param>
/// <response code="200">Item marked as favorite.</response>
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
- [HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
+ [HttpPost("UserFavoriteItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+ public ActionResult<UserItemDataDto> MarkFavoriteItem(
+ [FromQuery] Guid? userId,
+ [FromRoute, Required] Guid itemId)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -206,17 +258,36 @@ public class UserLibraryController : BaseJellyfinApiController
}
/// <summary>
+ /// Marks an item as a favorite.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Item marked as favorite.</response>
+ /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+ [HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<UserItemDataDto> MarkFavoriteItemLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId)
+ => MarkFavoriteItem(userId, itemId);
+
+ /// <summary>
/// Unmarks item as a favorite.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="itemId">Item id.</param>
/// <response code="200">Item unmarked as favorite.</response>
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
- [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
+ [HttpDelete("UserFavoriteItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+ public ActionResult<UserItemDataDto> UnmarkFavoriteItem(
+ [FromQuery] Guid? userId,
+ [FromRoute, Required] Guid itemId)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -242,17 +313,36 @@ public class UserLibraryController : BaseJellyfinApiController
}
/// <summary>
+ /// Unmarks item as a favorite.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Item unmarked as favorite.</response>
+ /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+ [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<UserItemDataDto> UnmarkFavoriteItemLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId)
+ => UnmarkFavoriteItem(userId, itemId);
+
+ /// <summary>
/// Deletes a user's saved personal rating for an item.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="itemId">Item id.</param>
/// <response code="200">Personal rating removed.</response>
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
- [HttpDelete("Users/{userId}/Items/{itemId}/Rating")]
+ [HttpDelete("UserItems/{itemId}/Rating")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+ public ActionResult<UserItemDataDto> DeleteUserItemRating(
+ [FromQuery] Guid? userId,
+ [FromRoute, Required] Guid itemId)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -278,6 +368,22 @@ public class UserLibraryController : BaseJellyfinApiController
}
/// <summary>
+ /// Deletes a user's saved personal rating for an item.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Personal rating removed.</response>
+ /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+ [HttpDelete("Users/{userId}/Items/{itemId}/Rating")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<UserItemDataDto> DeleteUserItemRatingLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId)
+ => DeleteUserItemRating(userId, itemId);
+
+ /// <summary>
/// Updates a user's rating for an item.
/// </summary>
/// <param name="userId">User id.</param>
@@ -285,11 +391,15 @@ public class UserLibraryController : BaseJellyfinApiController
/// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param>
/// <response code="200">Item rating updated.</response>
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
- [HttpPost("Users/{userId}/Items/{itemId}/Rating")]
+ [HttpPost("UserItems/{itemId}/Rating")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes)
+ public ActionResult<UserItemDataDto> UpdateUserItemRating(
+ [FromQuery] Guid? userId,
+ [FromRoute, Required] Guid itemId,
+ [FromQuery] bool? likes)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -315,17 +425,38 @@ public class UserLibraryController : BaseJellyfinApiController
}
/// <summary>
+ /// Updates a user's rating for an item.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param>
+ /// <response code="200">Item rating updated.</response>
+ /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+ [HttpPost("Users/{userId}/Items/{itemId}/Rating")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<UserItemDataDto> UpdateUserItemRatingLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId,
+ [FromQuery] bool? likes)
+ => UpdateUserItemRating(userId, itemId, likes);
+
+ /// <summary>
/// Gets local trailers for an item.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="itemId">Item id.</param>
/// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response>
/// <returns>The items local trailers.</returns>
- [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")]
+ [HttpGet("Items/{itemId}/LocalTrailers")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+ public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers(
+ [FromQuery] Guid? userId,
+ [FromRoute, Required] Guid itemId)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -360,17 +491,36 @@ public class UserLibraryController : BaseJellyfinApiController
}
/// <summary>
+ /// Gets local trailers for an item.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response>
+ /// <returns>The items local trailers.</returns>
+ [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailersLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId)
+ => GetLocalTrailers(userId, itemId);
+
+ /// <summary>
/// Gets special features for an item.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="itemId">Item id.</param>
/// <response code="200">Special features returned.</response>
/// <returns>An <see cref="OkResult"/> containing the special features.</returns>
- [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")]
+ [HttpGet("Items/{itemId}/SpecialFeatures")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+ public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures(
+ [FromQuery] Guid? userId,
+ [FromRoute, Required] Guid itemId)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -401,6 +551,22 @@ public class UserLibraryController : BaseJellyfinApiController
}
/// <summary>
+ /// Gets special features for an item.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Special features returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the special features.</returns>
+ [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeaturesLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] Guid itemId)
+ => GetSpecialFeatures(userId, itemId);
+
+ /// <summary>
/// Gets latest media.
/// </summary>
/// <param name="userId">User id.</param>
@@ -416,10 +582,10 @@ public class UserLibraryController : BaseJellyfinApiController
/// <param name="groupItems">Whether or not to group items into a parent container.</param>
/// <response code="200">Latest media returned.</response>
/// <returns>An <see cref="OkResult"/> containing the latest media.</returns>
- [HttpGet("Users/{userId}/Items/Latest")]
+ [HttpGet("Items/Latest")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia(
- [FromRoute, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
@@ -431,7 +597,8 @@ public class UserLibraryController : BaseJellyfinApiController
[FromQuery] int limit = 20,
[FromQuery] bool groupItems = true)
{
- var user = _userManager.GetUserById(userId);
+ var requestUserId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(requestUserId);
if (user is null)
{
return NotFound();
@@ -457,7 +624,7 @@ public class UserLibraryController : BaseJellyfinApiController
IsPlayed = isPlayed,
Limit = limit,
ParentId = parentId ?? Guid.Empty,
- UserId = userId,
+ UserId = requestUserId,
},
dtoOptions);
@@ -482,6 +649,51 @@ public class UserLibraryController : BaseJellyfinApiController
return Ok(dtos);
}
+ /// <summary>
+ /// Gets latest media.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="isPlayed">Filter by items that are played, or not.</param>
+ /// <param name="enableImages">Optional. include image information in output.</param>
+ /// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <param name="enableUserData">Optional. include user data.</param>
+ /// <param name="limit">Return item limit.</param>
+ /// <param name="groupItems">Whether or not to group items into a parent container.</param>
+ /// <response code="200">Latest media returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the latest media.</returns>
+ [HttpGet("Users/{userId}/Items/Latest")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<IEnumerable<BaseItemDto>> GetLatestMediaLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromQuery] Guid? parentId,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+ [FromQuery] bool? isPlayed,
+ [FromQuery] bool? enableImages,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int limit = 20,
+ [FromQuery] bool groupItems = true)
+ => GetLatestMedia(
+ userId,
+ parentId,
+ fields,
+ includeItemTypes,
+ isPlayed,
+ enableImages,
+ imageTypeLimit,
+ enableImageTypes,
+ enableUserData,
+ limit,
+ groupItems);
+
private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
{
if (item is Person)
@@ -539,48 +751,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/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs
index 035d04474..bf3ce1d39 100644
--- a/Jellyfin.Api/Controllers/UserViewsController.cs
+++ b/Jellyfin.Api/Controllers/UserViewsController.cs
@@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.UserViewDtos;
using Jellyfin.Data.Enums;
@@ -59,19 +60,17 @@ public class UserViewsController : BaseJellyfinApiController
/// <param name="includeHidden">Whether or not to include hidden content.</param>
/// <response code="200">User views returned.</response>
/// <returns>An <see cref="OkResult"/> containing the user views.</returns>
- [HttpGet("Users/{userId}/Views")]
+ [HttpGet("UserViews")]
[ProducesResponseType(StatusCodes.Status200OK)]
public QueryResult<BaseItemDto> GetUserViews(
- [FromRoute, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromQuery] bool? includeExternalContent,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] CollectionType?[] presetViews,
[FromQuery] bool includeHidden = false)
{
- var query = new UserViewQuery
- {
- UserId = userId,
- IncludeHidden = includeHidden
- };
+ userId = RequestHelpers.GetUserId(User, userId);
+
+ var query = new UserViewQuery { UserId = userId.Value, IncludeHidden = includeHidden };
if (includeExternalContent.HasValue)
{
@@ -92,7 +91,7 @@ public class UserViewsController : BaseJellyfinApiController
fields.Add(ItemFields.DisplayPreferencesId);
dtoOptions.Fields = fields.ToArray();
- var user = _userManager.GetUserById(userId);
+ var user = _userManager.GetUserById(userId.Value);
var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user))
.ToArray();
@@ -101,6 +100,26 @@ public class UserViewsController : BaseJellyfinApiController
}
/// <summary>
+ /// Get user views.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param>
+ /// <param name="presetViews">Preset views.</param>
+ /// <param name="includeHidden">Whether or not to include hidden content.</param>
+ /// <response code="200">User views returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the user views.</returns>
+ [HttpGet("Users/{userId}/Views")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public QueryResult<BaseItemDto> GetUserViewsLegacy(
+ [FromRoute, Required] Guid userId,
+ [FromQuery] bool? includeExternalContent,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] CollectionType?[] presetViews,
+ [FromQuery] bool includeHidden = false)
+ => GetUserViews(userId, includeExternalContent, presetViews, includeHidden);
+
+ /// <summary>
/// Get user view grouping options.
/// </summary>
/// <param name="userId">User id.</param>
@@ -110,12 +129,13 @@ public class UserViewsController : BaseJellyfinApiController
/// An <see cref="OkResult"/> containing the user view grouping options
/// or a <see cref="NotFoundResult"/> if user not found.
/// </returns>
- [HttpGet("Users/{userId}/GroupingOptions")]
+ [HttpGet("UserViews/GroupingOptions")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute, Required] Guid userId)
+ public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromQuery] Guid? userId)
{
- var user = _userManager.GetUserById(userId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
@@ -133,4 +153,23 @@ public class UserViewsController : BaseJellyfinApiController
.OrderBy(i => i.Name)
.AsEnumerable());
}
+
+ /// <summary>
+ /// Get user view grouping options.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <response code="200">User view grouping options returned.</response>
+ /// <response code="404">User not found.</response>
+ /// <returns>
+ /// An <see cref="OkResult"/> containing the user view grouping options
+ /// or a <see cref="NotFoundResult"/> if user not found.
+ /// </returns>
+ [HttpGet("Users/{userId}/GroupingOptions")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [Obsolete("Kept for backwards compatibility")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptionsLegacy(
+ [FromRoute, Required] Guid userId)
+ => GetGroupingOptions(userId);
}
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.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/MediaStreamProtocol.cs b/Jellyfin.Data/Enums/MediaStreamProtocol.cs
new file mode 100644
index 000000000..965edd6c1
--- /dev/null
+++ b/Jellyfin.Data/Enums/MediaStreamProtocol.cs
@@ -0,0 +1,20 @@
+using System.ComponentModel;
+
+namespace Jellyfin.Data.Enums;
+
+/// <summary>
+/// Media streaming protocol.
+/// </summary>
+[DefaultValue(Http)]
+public enum MediaStreamProtocol
+{
+ /// <summary>
+ /// HTTP.
+ /// </summary>
+ Http = 0,
+
+ /// <summary>
+ /// HTTP Live Streaming.
+ /// </summary>
+ Hls = 1
+}
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/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/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs
index 757b56a49..44aa43044 100644
--- a/Jellyfin.Server/Migrations/MigrationRunner.cs
+++ b/Jellyfin.Server/Migrations/MigrationRunner.cs
@@ -43,7 +43,8 @@ namespace Jellyfin.Server.Migrations
typeof(Routines.MigrateAuthenticationDb),
typeof(Routines.FixPlaylistOwner),
typeof(Routines.MigrateRatingLevels),
- typeof(Routines.AddDefaultCastReceivers)
+ typeof(Routines.AddDefaultCastReceivers),
+ typeof(Routines.UpdateDefaultPluginRepository)
};
/// <summary>
diff --git a/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs
new file mode 100644
index 000000000..7e8c8ac87
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs
@@ -0,0 +1,52 @@
+using System;
+using MediaBrowser.Controller.Configuration;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to update the default Jellyfin plugin repository.
+/// </summary>
+public class UpdateDefaultPluginRepository : IMigrationRoutine
+{
+ private const string NewRepositoryUrl = "https://repo.jellyfin.org/files/plugin/manifest.json";
+ private const string OldRepositoryUrl = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json";
+
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UpdateDefaultPluginRepository"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public UpdateDefaultPluginRepository(IServerConfigurationManager serverConfigurationManager)
+ {
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ /// <inheritdoc />
+ public Guid Id => new("852816E0-2712-49A9-9240-C6FC5FCAD1A8");
+
+ /// <inheritdoc />
+ public string Name => "UpdateDefaultPluginRepository10.9";
+
+ /// <inheritdoc />
+ public bool PerformOnNewInstall => true;
+
+ /// <inheritdoc />
+ public void Perform()
+ {
+ var updated = false;
+ foreach (var repo in _serverConfigurationManager.Configuration.PluginRepositories)
+ {
+ if (string.Equals(repo.Url, OldRepositoryUrl, StringComparison.OrdinalIgnoreCase))
+ {
+ repo.Url = NewRepositoryUrl;
+ updated = true;
+ }
+ }
+
+ if (updated)
+ {
+ _serverConfigurationManager.SaveConfiguration();
+ }
+ }
+}
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 558ad5b7b..e9fb3e4c2 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -6,9 +6,8 @@ using System.Net.Mime;
using System.Text;
using Emby.Server.Implementations.EntryPoints;
using Jellyfin.Api.Middleware;
-using Jellyfin.LiveTv;
-using Jellyfin.LiveTv.EmbyTV;
using Jellyfin.LiveTv.Extensions;
+using Jellyfin.LiveTv.Recordings;
using Jellyfin.MediaEncoding.Hls.Extensions;
using Jellyfin.Networking;
using Jellyfin.Networking.HappyEyeballs;
@@ -128,7 +127,7 @@ namespace Jellyfin.Server
services.AddHlsPlaylistGenerator();
services.AddLiveTvServices();
- services.AddHostedService<LiveTvHost>();
+ services.AddHostedService<RecordingsHost>();
services.AddHostedService<AutoDiscoveryHost>();
services.AddHostedService<PortForwardingHost>();
services.AddHostedService<NfoUserDataSaver>();
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.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/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..6532f7a34 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>
@@ -508,8 +517,9 @@ namespace MediaBrowser.Controller.Library
/// <param name="item">The item.</param>
/// <param name="image">The image.</param>
/// <param name="imageIndex">Index of the image.</param>
+ /// <param name="removeOnFailure">Whether to remove the image from the item on failure.</param>
/// <returns>Task.</returns>
- Task<ItemImageInfo> ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex);
+ Task<ItemImageInfo> ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex, bool removeOnFailure = true);
/// <summary>
/// Gets the items.
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 7da455b8d..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>
@@ -108,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>
@@ -223,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>
@@ -240,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>
@@ -272,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..1e71b87eb 100644
--- a/MediaBrowser.Controller/Lyrics/ILyricManager.cs
+++ b/MediaBrowser.Controller/Lyrics/ILyricManager.cs
@@ -1,5 +1,13 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+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 +17,103 @@ 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>
+ /// Saves new lyrics.
+ /// </summary>
+ /// <param name="audio">The audio file the lyrics belong to.</param>
+ /// <param name="format">The lyrics format.</param>
+ /// <param name="lyrics">The lyrics.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ Task<LyricDto?> SaveLyricAsync(Audio audio, string format, string lyrics);
+
+ /// <summary>
+ /// Saves new lyrics.
+ /// </summary>
+ /// <param name="audio">The audio file the lyrics belong to.</param>
+ /// <param name="format">The lyrics format.</param>
+ /// <param name="lyrics">The lyrics.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ Task<LyricDto?> SaveLyricAsync(Audio audio, string format, Stream lyrics);
+
+ /// <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/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index ea39891a6..946f7266c 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -3012,7 +3012,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
return string.Format(
CultureInfo.InvariantCulture,
- @"scale=-1:{1}:fast_bilinear,crop,pad=max({0}\,iw):max({1}\,ih):(ow-iw)/2:(oh-ih)/2:black@0,crop={0}:{1}",
+ @"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);
}
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index ff91a60a7..a97cca6b8 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -1,7 +1,7 @@
#pragma warning disable CS1591
using System;
-using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
@@ -9,6 +9,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
+using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
@@ -230,6 +231,8 @@ namespace MediaBrowser.MediaEncoding.Attachments
MediaAttachment mediaAttachment,
CancellationToken cancellationToken)
{
+ await CacheAllAttachments(mediaPath, inputFile, mediaSource, cancellationToken).ConfigureAwait(false);
+
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, mediaAttachment.Index);
await ExtractAttachment(inputFile, mediaSource, mediaAttachment.Index, outputPath, cancellationToken)
.ConfigureAwait(false);
@@ -237,6 +240,159 @@ namespace MediaBrowser.MediaEncoding.Attachments
return outputPath;
}
+ private async Task CacheAllAttachments(
+ string mediaPath,
+ string inputFile,
+ MediaSourceInfo mediaSource,
+ CancellationToken cancellationToken)
+ {
+ var outputFileLocks = new List<AsyncKeyedLockReleaser<string>>();
+ var extractableAttachmentIds = new List<int>();
+
+ try
+ {
+ foreach (var attachment in mediaSource.MediaAttachments)
+ {
+ var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachment.Index);
+
+ var @outputFileLock = _semaphoreLocks.GetOrAdd(outputPath);
+ await @outputFileLock.SemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ if (File.Exists(outputPath))
+ {
+ @outputFileLock.Dispose();
+ continue;
+ }
+
+ outputFileLocks.Add(@outputFileLock);
+ extractableAttachmentIds.Add(attachment.Index);
+ }
+
+ if (extractableAttachmentIds.Count > 0)
+ {
+ await CacheAllAttachmentsInternal(mediaPath, inputFile, mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Unable to cache media attachments for File:{File}", mediaPath);
+ }
+ finally
+ {
+ foreach (var @outputFileLock in outputFileLocks)
+ {
+ @outputFileLock.Dispose();
+ }
+ }
+ }
+
+ private async Task CacheAllAttachmentsInternal(
+ string mediaPath,
+ string inputFile,
+ MediaSourceInfo mediaSource,
+ List<int> extractableAttachmentIds,
+ CancellationToken cancellationToken)
+ {
+ var outputPaths = new List<string>();
+ var processArgs = string.Empty;
+
+ foreach (var attachmentId in extractableAttachmentIds)
+ {
+ var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachmentId);
+
+ Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));
+
+ outputPaths.Add(outputPath);
+ processArgs += string.Format(
+ CultureInfo.InvariantCulture,
+ " -dump_attachment:{0} \"{1}\"",
+ attachmentId,
+ EncodingUtils.NormalizePath(outputPath));
+ }
+
+ processArgs += string.Format(
+ CultureInfo.InvariantCulture,
+ " -i \"{0}\" -t 0 -f null null",
+ inputFile);
+
+ int exitCode;
+
+ using (var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ Arguments = processArgs,
+ FileName = _mediaEncoder.EncoderPath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WindowStyle = ProcessWindowStyle.Hidden,
+ ErrorDialog = false
+ },
+ EnableRaisingEvents = true
+ })
+ {
+ _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
+
+ process.Start();
+
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken).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 media attachment due to failure: {Path}", outputPath);
+ _fileSystem.DeleteFile(outputPath);
+ }
+ catch (FileNotFoundException)
+ {
+ // ffmpeg failed, so it is normal that one or more expected output files do not exist.
+ // There is no need to log anything for the user here.
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error deleting extracted media attachment {Path}", outputPath);
+ }
+ }
+ }
+ else
+ {
+ foreach (var outputPath in outputPaths)
+ {
+ if (!File.Exists(outputPath))
+ {
+ _logger.LogError("ffmpeg media attachment extraction failed for {InputPath} to {OutputPath}", inputFile, outputPath);
+ failed = true;
+ continue;
+ }
+
+ _logger.LogInformation("ffmpeg media attachment extraction completed for {InputPath} to {OutputPath}", inputFile, outputPath);
+ }
+ }
+
+ if (failed)
+ {
+ throw new FfmpegException(
+ string.Format(CultureInfo.InvariantCulture, "ffmpeg media attachment extraction failed for {0}", inputFile));
+ }
+ }
+
private async Task ExtractAttachment(
string inputFile,
MediaSourceInfo mediaSource,
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index 146b30643..8bace15c6 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -437,7 +437,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
}
}
- using var process = new Process
+ var process = new Process
{
StartInfo = new ProcessStartInfo
{
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 e6b7f4d9b..7d9449b74 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -557,7 +557,7 @@ namespace MediaBrowser.Model.Dlna
private static void SetStreamInfoOptionsFromDirectPlayProfile(MediaOptions options, MediaSourceInfo item, StreamInfo playlistItem, DirectPlayProfile? directPlayProfile)
{
var container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Video, directPlayProfile);
- var protocol = "http";
+ var protocol = MediaStreamProtocol.Http;
item.TranscodingContainer = container;
item.TranscodingSubProtocol = protocol;
@@ -648,7 +648,7 @@ namespace MediaBrowser.Model.Dlna
if (directPlay == PlayMethod.DirectPlay)
{
- playlistItem.SubProtocol = "http";
+ playlistItem.SubProtocol = MediaStreamProtocol.Http;
var audioStreamIndex = directPlayInfo.AudioStreamIndex ?? audioStream?.Index;
if (audioStreamIndex.HasValue)
@@ -803,7 +803,7 @@ namespace MediaBrowser.Model.Dlna
var videoCodecs = ContainerProfile.SplitValue(videoCodec);
// Enforce HLS video codec restrictions
- if (string.Equals(playlistItem.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
+ if (playlistItem.SubProtocol == MediaStreamProtocol.Hls)
{
videoCodecs = videoCodecs.Where(codec => _supportedHlsVideoCodecs.Contains(codec)).ToArray();
}
@@ -840,7 +840,7 @@ namespace MediaBrowser.Model.Dlna
var audioCodecs = ContainerProfile.SplitValue(audioCodec);
// Enforce HLS audio codec restrictions
- if (string.Equals(playlistItem.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
+ if (playlistItem.SubProtocol == MediaStreamProtocol.Hls)
{
if (string.Equals(playlistItem.Container, "mp4", StringComparison.OrdinalIgnoreCase))
{
@@ -1358,9 +1358,9 @@ namespace MediaBrowser.Model.Dlna
PlayMethod playMethod,
ITranscoderSupport transcoderSupport,
string? outputContainer,
- string? transcodingSubProtocol)
+ MediaStreamProtocol? transcodingSubProtocol)
{
- if (!subtitleStream.IsExternal && (playMethod != PlayMethod.Transcode || !string.Equals(transcodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)))
+ if (!subtitleStream.IsExternal && (playMethod != PlayMethod.Transcode || transcodingSubProtocol != MediaStreamProtocol.Hls))
{
// Look for supported embedded subs of the same format
foreach (var profile in subtitleProfiles)
diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs
index fc146df30..cd6d34be2 100644
--- a/MediaBrowser.Model/Dlna/StreamInfo.cs
+++ b/MediaBrowser.Model/Dlna/StreamInfo.cs
@@ -36,7 +36,7 @@ namespace MediaBrowser.Model.Dlna
public string? Container { get; set; }
- public string? SubProtocol { get; set; }
+ public MediaStreamProtocol SubProtocol { get; set; }
public long StartPositionTicks { get; set; }
@@ -670,7 +670,7 @@ namespace MediaBrowser.Model.Dlna
if (MediaType == DlnaProfileType.Audio)
{
- if (string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
+ if (SubProtocol == MediaStreamProtocol.Hls)
{
return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString);
}
@@ -678,7 +678,7 @@ namespace MediaBrowser.Model.Dlna
return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString);
}
- if (string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
+ if (SubProtocol == MediaStreamProtocol.Hls)
{
return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString);
}
@@ -716,9 +716,7 @@ namespace MediaBrowser.Model.Dlna
long startPositionTicks = item.StartPositionTicks;
- var isHls = string.Equals(item.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase);
-
- if (isHls)
+ if (item.SubProtocol == MediaStreamProtocol.Hls)
{
list.Add(new NameValuePair("StartTimeTicks", string.Empty));
}
@@ -780,7 +778,7 @@ namespace MediaBrowser.Model.Dlna
list.Add(new NameValuePair("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty));
- if (isHls)
+ if (item.SubProtocol == MediaStreamProtocol.Hls)
{
list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty));
@@ -831,7 +829,7 @@ namespace MediaBrowser.Model.Dlna
var list = new List<SubtitleStreamInfo>();
// HLS will preserve timestamps so we can just grab the full subtitle stream
- long startPositionTicks = string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase)
+ long startPositionTicks = SubProtocol == MediaStreamProtocol.Hls
? 0
: (PlayMethod == PlayMethod.Transcode && !CopyTimestamps ? StartPositionTicks : 0);
diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs
index b4f6ec255..8f4f3e2f8 100644
--- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs
+++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs
@@ -3,6 +3,7 @@
using System;
using System.ComponentModel;
using System.Xml.Serialization;
+using Jellyfin.Data.Enums;
namespace MediaBrowser.Model.Dlna
{
@@ -26,7 +27,7 @@ namespace MediaBrowser.Model.Dlna
public string AudioCodec { get; set; } = string.Empty;
[XmlAttribute("protocol")]
- public string Protocol { get; set; } = string.Empty;
+ public MediaStreamProtocol Protocol { get; set; } = MediaStreamProtocol.Http;
[DefaultValue(false)]
[XmlAttribute("estimateContentLength")]
diff --git a/MediaBrowser.Model/Drawing/ImageFormat.cs b/MediaBrowser.Model/Drawing/ImageFormat.cs
index 511c16a4e..6a586f6e3 100644
--- a/MediaBrowser.Model/Drawing/ImageFormat.cs
+++ b/MediaBrowser.Model/Drawing/ImageFormat.cs
@@ -6,28 +6,33 @@ namespace MediaBrowser.Model.Drawing
public enum ImageFormat
{
/// <summary>
- /// The BMP.
+ /// BMP format.
/// </summary>
Bmp,
/// <summary>
- /// The GIF.
+ /// GIF format.
/// </summary>
Gif,
/// <summary>
- /// The JPG.
+ /// JPG format.
/// </summary>
Jpg,
/// <summary>
- /// The PNG.
+ /// PNG format.
/// </summary>
Png,
/// <summary>
- /// The webp.
+ /// WEBP format.
/// </summary>
- Webp
+ Webp,
+
+ /// <summary>
+ /// SVG format.
+ /// </summary>
+ Svg,
}
}
diff --git a/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs b/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs
index 1bb24112e..1c60ba460 100644
--- a/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs
+++ b/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs
@@ -22,6 +22,7 @@ public static class ImageFormatExtensions
ImageFormat.Jpg => MediaTypeNames.Image.Jpeg,
ImageFormat.Png => "image/png",
ImageFormat.Webp => "image/webp",
+ ImageFormat.Svg => "image/svg+xml",
_ => throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(ImageFormat))
};
@@ -39,6 +40,7 @@ public static class ImageFormatExtensions
ImageFormat.Jpg => ".jpg",
ImageFormat.Png => ".png",
ImageFormat.Webp => ".webp",
+ ImageFormat.Svg => ".svg",
_ => throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(ImageFormat))
};
}
diff --git a/MediaBrowser.Model/Dto/MediaSourceInfo.cs b/MediaBrowser.Model/Dto/MediaSourceInfo.cs
index 520832aee..b7236b1e8 100644
--- a/MediaBrowser.Model/Dto/MediaSourceInfo.cs
+++ b/MediaBrowser.Model/Dto/MediaSourceInfo.cs
@@ -4,6 +4,8 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Session;
@@ -102,7 +104,7 @@ namespace MediaBrowser.Model.Dto
public string TranscodingUrl { get; set; }
- public string TranscodingSubProtocol { get; set; }
+ public MediaStreamProtocol TranscodingSubProtocol { get; set; }
public string TranscodingContainer { 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/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 7b510a337..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" },
@@ -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/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/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/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/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..f4b18a8c1 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,260 @@ 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)
{
- _lyricProviders = lyricProviders.OrderBy(i => i.Priority).ToArray();
- _lyricParsers = lyricParsers.OrderBy(i => i.Priority).ToArray();
+ _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 async Task<LyricResponse?> GetLyrics(BaseItem item)
+ public event EventHandler<LyricDownloadFailureEventArgs>? LyricDownloadFailure;
+
+ /// <inheritdoc />
+ public Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken cancellationToken)
{
- foreach (ILyricProvider provider in _lyricProviders)
+ 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)
{
- var lyrics = await provider.GetLyrics(item).ConfigureAwait(false);
- if (lyrics is null)
+ foreach (var provider in providers)
{
- continue;
+ var providerResult = await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false);
+ if (providerResult.Count > 0)
+ {
+ return providerResult;
+ }
}
- foreach (ILyricParser parser in _lyricParsers)
+ 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)
{
- var result = parser.ParseLyrics(lyrics);
- if (result is not null)
+ _logger.LogDebug("Unable to download lyrics for {LyricId}", lyricId);
+ return null;
+ }
+
+ var parsedLyrics = await InternalParseRemoteLyricsAsync(response.Format, response.Stream, cancellationToken).ConfigureAwait(false);
+ if (parsedLyrics is null)
+ {
+ return null;
+ }
+
+ await TrySaveLyric(audio, libraryOptions, response.Format, response.Stream).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?> SaveLyricAsync(Audio audio, string format, string lyrics)
+ {
+ ArgumentNullException.ThrowIfNull(audio);
+ ArgumentException.ThrowIfNullOrEmpty(format);
+ ArgumentException.ThrowIfNullOrEmpty(lyrics);
+
+ var bytes = Encoding.UTF8.GetBytes(lyrics);
+ using var lyricStream = new MemoryStream(bytes, 0, bytes.Length, false, true);
+ return await SaveLyricAsync(audio, format, lyricStream).ConfigureAwait(false);
+ }
+
+ /// <inheritdoc />
+ public async Task<LyricDto?> SaveLyricAsync(Audio audio, string format, Stream lyrics)
+ {
+ ArgumentNullException.ThrowIfNull(audio);
+ ArgumentException.ThrowIfNullOrEmpty(format);
+ ArgumentNullException.ThrowIfNull(lyrics);
+
+ var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
+
+ var parsed = await InternalParseRemoteLyricsAsync(format, lyrics, CancellationToken.None).ConfigureAwait(false);
+ if (parsed is null)
+ {
+ return null;
+ }
+
+ await TrySaveLyric(audio, libraryOptions, format, lyrics).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.Format, lyricResponse.Stream, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// <inheritdoc />
+ public Task DeleteLyricsAsync(Audio audio)
+ {
+ ArgumentNullException.ThrowIfNull(audio);
+ var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
+ {
+ ItemId = audio.Id,
+ Type = MediaStreamType.Lyric
+ });
+
+ foreach (var stream in streams)
+ {
+ var path = stream.Path;
+ _libraryMonitor.ReportFileSystemChangeBeginning(path);
+
+ try
+ {
+ _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();
+ }
+
+ /// <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 parsedLyrics = parser.ParseLyrics(lyricFile);
+ if (parsedLyrics is not null)
{
- return result;
+ return parsedLyrics;
}
}
}
@@ -49,22 +289,179 @@ 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(string format, Stream lyricStream, CancellationToken cancellationToken)
+ {
+ lyricStream.Seek(0, SeekOrigin.Begin);
+ using var streamReader = new StreamReader(lyricStream, leaveOpen: true);
+ var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ var lyricFile = new LyricFile($"lyric.{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.Format, result.Lyrics.Stream, 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,
+ string format,
+ Stream lyricStream)
{
- foreach (ILyricProvider provider in _lyricProviders)
+ var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia;
+
+ var memoryStream = new MemoryStream();
+ await using (memoryStream.ConfigureAwait(false))
{
- if (item is null)
+ await using (lyricStream.ConfigureAwait(false))
+ {
+ lyricStream.Seek(0, SeekOrigin.Begin);
+ await lyricStream.CopyToAsync(memoryStream).ConfigureAwait(false);
+ memoryStream.Seek(0, SeekOrigin.Begin);
+ }
+
+ var savePaths = new List<string>();
+ var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + format.ReplaceLineEndings(string.Empty).ToLowerInvariant();
+
+ if (saveInMediaFolder)
+ {
+ 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
{
- continue;
+ _logger.LogError("An uploaded lyric could not be saved because the resulting paths were invalid.");
}
+ }
+ }
- if (provider.HasLyrics(item))
+ 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
{
- return true;
+ 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;
}
- return false;
+ 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 2e9547bf3..f34034964 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -9,6 +9,7 @@ using System.Net.Http;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
+using AsyncKeyedLock;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
using Jellyfin.Extensions;
@@ -21,6 +22,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;
@@ -29,6 +31,7 @@ using MediaBrowser.Model.Extensions;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Net;
using MediaBrowser.Model.Providers;
+using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Book = MediaBrowser.Controller.Entities.Book;
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
@@ -52,11 +55,19 @@ 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();
private readonly CancellationTokenSource _disposeCancellationTokenSource = new();
private readonly PriorityQueue<(Guid ItemId, MetadataRefreshOptions RefreshOptions), RefreshPriority> _refreshQueue = new();
+ private readonly IMemoryCache _memoryCache;
+
+ private readonly AsyncKeyedLocker<string> _imageSaveLock = new(o =>
+ {
+ o.PoolSize = 20;
+ o.PoolInitialFill = 1;
+ });
private IImageProvider[] _imageProviders = Array.Empty<IImageProvider>();
private IMetadataService[] _metadataServices = Array.Empty<IMetadataService>();
@@ -78,6 +89,8 @@ 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>
+ /// <param name="memoryCache">The memory cache.</param>
public ProviderManager(
IHttpClientFactory httpClientFactory,
ISubtitleManager subtitleManager,
@@ -87,7 +100,9 @@ namespace MediaBrowser.Providers.Manager
IFileSystem fileSystem,
IServerApplicationPaths appPaths,
ILibraryManager libraryManager,
- IBaseItemManager baseItemManager)
+ IBaseItemManager baseItemManager,
+ ILyricManager lyricManager,
+ IMemoryCache memoryCache)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
@@ -98,6 +113,8 @@ namespace MediaBrowser.Providers.Manager
_libraryManager = libraryManager;
_subtitleManager = subtitleManager;
_baseItemManager = baseItemManager;
+ _lyricManager = lyricManager;
+ _memoryCache = memoryCache;
}
/// <inheritdoc/>
@@ -145,52 +162,79 @@ namespace MediaBrowser.Providers.Manager
/// <inheritdoc/>
public async Task SaveImage(BaseItem item, string url, ImageType type, int? imageIndex, CancellationToken cancellationToken)
{
- var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
- using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
-
- if (response.StatusCode != HttpStatusCode.OK)
+ using (await _imageSaveLock.LockAsync(url, cancellationToken).ConfigureAwait(false))
{
- throw new HttpRequestException("Invalid image received.", null, response.StatusCode);
- }
+ if (_memoryCache.TryGetValue(url, out (string ContentType, byte[] ImageContents)? cachedValue)
+ && cachedValue is not null)
+ {
+ var imageContents = cachedValue.Value.ImageContents;
+ var cacheStream = new MemoryStream(imageContents, 0, imageContents.Length, false);
+ await using (cacheStream.ConfigureAwait(false))
+ {
+ await SaveImage(
+ item,
+ cacheStream,
+ cachedValue.Value.ContentType,
+ type,
+ imageIndex,
+ cancellationToken).ConfigureAwait(false);
+ return;
+ }
+ }
- var contentType = response.Content.Headers.ContentType?.MediaType;
+ var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
+ using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
- // Workaround for tvheadend channel icons
- // TODO: Isolate this hack into the tvh plugin
- if (string.IsNullOrEmpty(contentType))
- {
- if (url.Contains("/imagecache/", StringComparison.OrdinalIgnoreCase))
+ response.EnsureSuccessStatusCode();
+
+ var contentType = response.Content.Headers.ContentType?.MediaType;
+
+ // Workaround for tvheadend channel icons
+ // TODO: Isolate this hack into the tvh plugin
+ if (string.IsNullOrEmpty(contentType))
{
- contentType = "image/png";
+ if (url.Contains("/imagecache/", StringComparison.OrdinalIgnoreCase))
+ {
+ contentType = MediaTypeNames.Image.Png;
+ }
+ else
+ {
+ throw new HttpRequestException("Invalid image received: contentType not set.", null, response.StatusCode);
+ }
}
- else
+
+ // TVDb will sometimes serve a rubbish 404 html page with a 200 OK code, because reasons...
+ if (contentType.Equals(MediaTypeNames.Text.Html, StringComparison.OrdinalIgnoreCase))
{
- throw new HttpRequestException("Invalid image received: contentType not set.", null, response.StatusCode);
+ throw new HttpRequestException("Invalid image received.", null, HttpStatusCode.NotFound);
}
- }
- // TVDb will sometimes serve a rubbish 404 html page with a 200 OK code, because reasons...
- if (contentType.Equals(MediaTypeNames.Text.Html, StringComparison.OrdinalIgnoreCase))
- {
- throw new HttpRequestException("Invalid image received.", null, HttpStatusCode.NotFound);
- }
+ // some iptv/epg providers don't correctly report media type, extract from url if no extension found
+ if (string.IsNullOrWhiteSpace(MimeTypes.ToExtension(contentType)))
+ {
+ // Strip query parameters from url to get actual path.
+ contentType = MimeTypes.GetMimeType(new Uri(url).GetLeftPart(UriPartial.Path));
+ }
- // some iptv/epg providers don't correctly report media type, extract from url if no extension found
- if (string.IsNullOrWhiteSpace(MimeTypes.ToExtension(contentType)))
- {
- contentType = MimeTypes.GetMimeType(url);
- }
+ if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new HttpRequestException($"Request returned {contentType} instead of an image type", null, HttpStatusCode.NotFound);
+ }
- var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
- await using (stream.ConfigureAwait(false))
- {
- await SaveImage(
- item,
- stream,
- contentType,
- type,
- imageIndex,
- cancellationToken).ConfigureAwait(false);
+ var responseBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
+ var stream = new MemoryStream(responseBytes, 0, responseBytes.Length, false);
+ await using (stream.ConfigureAwait(false))
+ {
+ _memoryCache.Set(url, (contentType, responseBytes), TimeSpan.FromSeconds(10));
+
+ await SaveImage(
+ item,
+ stream,
+ contentType,
+ type,
+ imageIndex,
+ cancellationToken).ConfigureAwait(false);
+ }
}
}
@@ -503,15 +547,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>()
@@ -1103,6 +1154,7 @@ namespace MediaBrowser.Providers.Manager
}
_disposeCancellationTokenSource.Dispose();
+ _imageSaveLock.Dispose();
}
_disposed = true;
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index 7a50c6cf4..dfb6319ac 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -15,6 +15,7 @@
</ItemGroup>
<ItemGroup>
+ <PackageReference Include="AsyncKeyedLock" />
<PackageReference Include="LrcParser" />
<PackageReference Include="MetaBrainz.MusicBrainz" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
index f718325df..4d4b59b8c 100644
--- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
+++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
@@ -10,6 +10,7 @@ using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
@@ -35,6 +36,8 @@ namespace MediaBrowser.Providers.MediaInfo
private readonly IItemRepository _itemRepo;
private readonly ILibraryManager _libraryManager;
private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly LyricResolver _lyricResolver;
+ private readonly ILyricManager _lyricManager;
/// <summary>
/// Initializes a new instance of the <see cref="AudioFileProber"/> class.
@@ -44,18 +47,24 @@ 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>
+ /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
public AudioFileProber(
ILogger<AudioFileProber> logger,
IMediaSourceManager mediaSourceManager,
IMediaEncoder mediaEncoder,
IItemRepository itemRepo,
- ILibraryManager libraryManager)
+ ILibraryManager libraryManager,
+ LyricResolver lyricResolver,
+ ILyricManager lyricManager)
{
_logger = logger;
_mediaEncoder = mediaEncoder;
_itemRepo = itemRepo;
_libraryManager = libraryManager;
_mediaSourceManager = mediaSourceManager;
+ _lyricResolver = lyricResolver;
+ _lyricManager = lyricManager;
}
[GeneratedRegex(@"I:\s+(.*?)\s+LUFS")]
@@ -103,7 +112,7 @@ namespace MediaBrowser.Providers.MediaInfo
cancellationToken.ThrowIfCancellationRequested();
- Fetch(item, result, cancellationToken);
+ await FetchAsync(item, result, options, cancellationToken).ConfigureAwait(false);
}
var libraryOptions = _libraryManager.GetLibraryOptions(item);
@@ -205,8 +214,14 @@ namespace MediaBrowser.Providers.MediaInfo
/// </summary>
/// <param name="audio">The <see cref="Audio"/>.</param>
/// <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, CancellationToken cancellationToken)
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ private async Task FetchAsync(
+ Audio audio,
+ Model.MediaInfo.MediaInfo mediaInfo,
+ MetadataRefreshOptions options,
+ CancellationToken cancellationToken)
{
audio.Container = mediaInfo.Container;
audio.TotalBitrate = mediaInfo.Bitrate;
@@ -216,19 +231,25 @@ namespace MediaBrowser.Providers.MediaInfo
if (!audio.IsLocked)
{
- FetchDataFromTags(audio);
+ await FetchDataFromTags(audio, options).ConfigureAwait(false);
}
- _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>
/// Fetches data from the tags.
/// </summary>
/// <param name="audio">The <see cref="Audio"/>.</param>
- private void FetchDataFromTags(Audio audio)
+ /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
+ private async Task FetchDataFromTags(Audio audio, MetadataRefreshOptions options)
{
- var file = TagLib.File.Create(audio.Path);
+ using var file = TagLib.File.Create(audio.Path);
var tagTypes = file.TagTypesOnDisk;
Tag? tags = null;
@@ -305,14 +326,45 @@ namespace MediaBrowser.Providers.MediaInfo
}
_libraryManager.UpdatePeople(audio, people);
- audio.Artists = performers;
- audio.AlbumArtists = albumArtists;
+
+ if (options.ReplaceAllMetadata && performers.Length != 0)
+ {
+ audio.Artists = performers;
+ }
+ else if (!options.ReplaceAllMetadata
+ && (audio.Artists is null || audio.Artists.Count == 0))
+ {
+ audio.Artists = performers;
+ }
+
+ if (options.ReplaceAllMetadata && albumArtists.Length != 0)
+ {
+ audio.AlbumArtists = albumArtists;
+ }
+ else if (!options.ReplaceAllMetadata
+ && (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0))
+ {
+ audio.AlbumArtists = albumArtists;
+ }
}
- audio.Name = tags.Title;
- audio.Album = tags.Album;
- audio.IndexNumber = Convert.ToInt32(tags.Track);
- audio.ParentIndexNumber = Convert.ToInt32(tags.Disc);
+ if (!audio.LockedFields.Contains(MetadataField.Name))
+ {
+ audio.Name = options.ReplaceAllMetadata || string.IsNullOrEmpty(audio.Name) ? tags.Title : audio.Name;
+ }
+
+ if (options.ReplaceAllMetadata)
+ {
+ audio.Album = tags.Album;
+ audio.IndexNumber = Convert.ToInt32(tags.Track);
+ audio.ParentIndexNumber = Convert.ToInt32(tags.Disc);
+ }
+ else
+ {
+ audio.Album ??= tags.Album;
+ audio.IndexNumber ??= Convert.ToInt32(tags.Track);
+ audio.ParentIndexNumber ??= Convert.ToInt32(tags.Disc);
+ }
if (tags.Year != 0)
{
@@ -323,15 +375,56 @@ namespace MediaBrowser.Providers.MediaInfo
if (!audio.LockedFields.Contains(MetadataField.Genres))
{
- audio.Genres = tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0
+ ? tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
+ : audio.Genres;
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId);
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId);
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId);
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId);
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzTrack, tags.MusicBrainzTrackId);
}
- audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId);
- audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId);
- audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId);
- audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId);
- audio.SetProviderId(MetadataProvider.MusicBrainzTrack, tags.MusicBrainzTrackId);
+ // Save extracted lyrics if they exist,
+ // and if we are replacing all metadata or the audio doesn't yet have lyrics.
+ if (!string.IsNullOrWhiteSpace(tags.Lyrics)
+ && (options.ReplaceAllMetadata || audio.GetMediaStreams().All(s => s.Type != MediaStreamType.Lyric)))
+ {
+ await _lyricManager.SaveLyricAsync(audio, "lrc", tags.Lyrics).ConfigureAwait(false);
+ }
}
}
+
+ 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/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
index 35ea04d21..5d0fccbe1 100644
--- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
+++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
@@ -324,20 +324,7 @@ namespace MediaBrowser.Providers.MediaInfo
return;
}
- // Use BD Info if it has multiple m2ts. Otherwise, treat it like a video file and rely more on ffprobe output
- int? currentHeight = null;
- int? currentWidth = null;
- int? currentBitRate = null;
-
- var videoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video);
-
- // Grab the values that ffprobe recorded
- if (videoStream is not null)
- {
- currentBitRate = videoStream.BitRate;
- currentWidth = videoStream.Width;
- currentHeight = videoStream.Height;
- }
+ var ffmpegVideoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video);
// Fill video properties from the BDInfo result
mediaStreams.Clear();
@@ -361,14 +348,16 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
- videoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video);
+ var blurayVideoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video);
// Use the ffprobe values if these are empty
- if (videoStream is not null)
+ if (blurayVideoStream is not null && ffmpegVideoStream is not null)
{
- videoStream.BitRate = videoStream.BitRate.GetValueOrDefault() == 0 ? currentBitRate : videoStream.BitRate;
- videoStream.Width = videoStream.Width.GetValueOrDefault() == 0 ? currentWidth : videoStream.Width;
- videoStream.Height = videoStream.Height.GetValueOrDefault() == 0 ? currentHeight : videoStream.Height;
+ // Always use ffmpeg's detected codec since that is what the rest of the codebase expects.
+ blurayVideoStream.Codec = ffmpegVideoStream.Codec;
+ blurayVideoStream.BitRate = blurayVideoStream.BitRate.GetValueOrDefault() == 0 ? ffmpegVideoStream.BitRate : blurayVideoStream.BitRate;
+ blurayVideoStream.Width = blurayVideoStream.Width.GetValueOrDefault() == 0 ? ffmpegVideoStream.Width : blurayVideoStream.Width;
+ blurayVideoStream.Height = blurayVideoStream.Height.GetValueOrDefault() == 0 ? ffmpegVideoStream.Width : blurayVideoStream.Height;
}
}
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..8bb8d5bb4 100644
--- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
@@ -13,6 +13,7 @@ using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
@@ -43,6 +44,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);
@@ -63,6 +65,7 @@ namespace MediaBrowser.Providers.MediaInfo
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/>.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="namingOptions">The <see cref="NamingOptions"/>.</param>
+ /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
public ProbeProvider(
IMediaSourceManager mediaSourceManager,
IMediaEncoder mediaEncoder,
@@ -76,12 +79,14 @@ namespace MediaBrowser.Providers.MediaInfo
ILibraryManager libraryManager,
IFileSystem fileSystem,
ILoggerFactory loggerFactory,
- NamingOptions namingOptions)
+ NamingOptions namingOptions,
+ ILyricManager lyricManager)
{
_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 +101,15 @@ namespace MediaBrowser.Providers.MediaInfo
libraryManager,
_audioResolver,
_subtitleResolver);
+
+ _audioProber = new AudioFileProber(
+ loggerFactory.CreateLogger<AudioFileProber>(),
+ mediaSourceManager,
+ mediaEncoder,
+ itemRepo,
+ libraryManager,
+ _lyricResolver,
+ lyricManager);
}
/// <inheritdoc />
@@ -123,23 +137,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/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs
index f35880a04..7e9befa8c 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs
@@ -1,6 +1,7 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
+using Jellyfin.Extensions.Json;
namespace MediaBrowser.Providers.Plugins.Omdb
{
@@ -12,7 +13,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
/// <inheritdoc />
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (reader.TokenType == JsonTokenType.Null)
+ if (reader.IsNull())
{
return null;
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
index a4c6cb47d..18cdba7a0 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
@@ -46,14 +46,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
}
/// <inheritdoc />
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new ImageType[]
- {
- ImageType.Primary,
- ImageType.Backdrop
- };
- }
+ public IEnumerable<ImageType> GetSupportedImages(BaseItem item) =>
+ [
+ ImageType.Primary,
+ ImageType.Backdrop,
+ ImageType.Thumb
+ ];
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
index bfec48e7c..1696a2c49 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
@@ -47,15 +47,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
}
/// <inheritdoc />
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new ImageType[]
- {
- ImageType.Primary,
- ImageType.Backdrop,
- ImageType.Logo
- };
- }
+ public IEnumerable<ImageType> GetSupportedImages(BaseItem item) =>
+ [
+ ImageType.Primary,
+ ImageType.Backdrop,
+ ImageType.Logo,
+ ImageType.Thumb
+ ];
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
index 192fb052d..2cb4fe1c1 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
@@ -46,15 +46,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
}
/// <inheritdoc />
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new ImageType[]
- {
- ImageType.Primary,
- ImageType.Backdrop,
- ImageType.Logo
- };
- }
+ public IEnumerable<ImageType> GetSupportedImages(BaseItem item) =>
+ [
+ ImageType.Primary,
+ ImageType.Backdrop,
+ ImageType.Logo,
+ ImageType.Thumb
+ ];
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
index 82f2c54f1..d704a5f49 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
@@ -591,6 +591,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
{
var image = images[i];
+ var imageType = type;
+ var language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, requestLanguage);
+
+ // Return Backdrops with a language specified (it has text) as Thumb.
+ if (imageType == ImageType.Backdrop && !string.IsNullOrEmpty(language))
+ {
+ imageType = ImageType.Thumb;
+ }
+
yield return new RemoteImageInfo
{
Url = GetUrl(size, image.FilePath),
@@ -598,9 +607,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
VoteCount = image.VoteCount,
Width = scaleImage ? null : image.Width,
Height = scaleImage ? null : image.Height,
- Language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, requestLanguage),
+ Language = language,
ProviderName = TmdbUtils.ProviderName,
- Type = type,
+ Type = imageType,
RatingType = RatingType.Score
};
}
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/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/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index 4ae5a9a48..a40719499 100644
--- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -19,8 +19,8 @@ namespace Jellyfin.Drawing.Skia;
/// </summary>
public class SkiaEncoder : IImageEncoder
{
+ private const string SvgFormat = "svg";
private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
-
private readonly ILogger<SkiaEncoder> _logger;
private readonly IApplicationPaths _appPaths;
private static readonly SKImageFilter _imageFilter;
@@ -89,12 +89,13 @@ public class SkiaEncoder : IImageEncoder
// working on windows at least
"cr2",
"nef",
- "arw"
+ "arw",
+ SvgFormat
};
/// <inheritdoc/>
public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
- => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png };
+ => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png, ImageFormat.Svg };
/// <summary>
/// Check if the native lib is available.
@@ -312,6 +313,31 @@ public class SkiaEncoder : IImageEncoder
return Decode(path, false, orientation, out _);
}
+ private SKBitmap? GetBitmapFromSvg(string path)
+ {
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException("File not found", path);
+ }
+
+ using var svg = SKSvg.CreateFromFile(path);
+ if (svg.Drawable is null)
+ {
+ return null;
+ }
+
+ var width = (int)Math.Round(svg.Drawable.Bounds.Width);
+ var height = (int)Math.Round(svg.Drawable.Bounds.Height);
+
+ var bitmap = new SKBitmap(width, height);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.DrawPicture(svg.Picture);
+ canvas.Flush();
+ canvas.Save();
+
+ return bitmap;
+ }
+
private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin)
{
var needsFlip = origin is SKEncodedOrigin.LeftBottom or SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightBottom or SKEncodedOrigin.RightTop;
@@ -402,6 +428,12 @@ public class SkiaEncoder : IImageEncoder
return inputPath;
}
+ if (outputFormat == ImageFormat.Svg
+ && !inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException($"Requested svg output from {inputFormat} input");
+ }
+
var skiaOutputFormat = GetImageFormat(outputFormat);
var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor);
@@ -409,7 +441,10 @@ public class SkiaEncoder : IImageEncoder
var blur = options.Blur ?? 0;
var hasIndicator = options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
- using var bitmap = GetBitmap(inputPath, autoOrient, orientation);
+ using var bitmap = inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase)
+ ? GetBitmapFromSvg(inputPath)
+ : GetBitmap(inputPath, autoOrient, orientation);
+
if (bitmap is null)
{
throw new InvalidDataException($"Skia unable to read image {inputPath}");
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs
new file mode 100644
index 000000000..06ecfc558
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverter.cs
@@ -0,0 +1,49 @@
+using System;
+using System.ComponentModel;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters;
+
+/// <summary>
+/// Json unknown enum converter.
+/// </summary>
+/// <typeparam name="T">The type of enum.</typeparam>
+public class JsonDefaultStringEnumConverter<T> : JsonConverter<T>
+ where T : struct, Enum
+{
+ private readonly JsonConverter<T> _baseConverter;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JsonDefaultStringEnumConverter{T}"/> class.
+ /// </summary>
+ /// <param name="baseConverter">The base json converter.</param>
+ public JsonDefaultStringEnumConverter(JsonConverter<T> baseConverter)
+ {
+ _baseConverter = baseConverter;
+ }
+
+ /// <inheritdoc />
+ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.IsNull() || reader.IsEmptyString())
+ {
+ var customValueAttribute = typeToConvert.GetCustomAttribute<DefaultValueAttribute>();
+ if (customValueAttribute?.Value is null)
+ {
+ throw new InvalidOperationException($"Default value not set for '{typeToConvert.Name}'");
+ }
+
+ return (T)customValueAttribute.Value;
+ }
+
+ return _baseConverter.Read(ref reader, typeToConvert, options);
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
+ {
+ _baseConverter.Write(writer, value, options);
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs
new file mode 100644
index 000000000..5a9bf546e
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonDefaultStringEnumConverterFactory.cs
@@ -0,0 +1,31 @@
+using System;
+using System.ComponentModel;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Extensions.Json.Converters;
+
+/// <summary>
+/// Utilizes the JsonStringEnumConverter and sets a default value if not provided.
+/// </summary>
+public class JsonDefaultStringEnumConverterFactory : JsonConverterFactory
+{
+ private static readonly JsonStringEnumConverter _baseConverterFactory = new();
+
+ /// <inheritdoc />
+ public override bool CanConvert(Type typeToConvert)
+ {
+ return _baseConverterFactory.CanConvert(typeToConvert)
+ && typeToConvert.IsDefined(typeof(DefaultValueAttribute));
+ }
+
+ /// <inheritdoc />
+ public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ {
+ var baseConverter = _baseConverterFactory.CreateConverter(typeToConvert, options);
+ var converterType = typeof(JsonDefaultStringEnumConverter<>).MakeGenericType(typeToConvert);
+
+ return (JsonConverter?)Activator.CreateInstance(converterType, baseConverter);
+ }
+}
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs
index ea6d141cb..2964c6943 100644
--- a/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs
@@ -12,7 +12,7 @@ namespace Jellyfin.Extensions.Json.Converters
{
/// <inheritdoc />
public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- => reader.TokenType == JsonTokenType.Null
+ => reader.IsNull()
? Guid.Empty
: ReadInternal(ref reader);
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs
index 28437023f..94004fa49 100644
--- a/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs
+++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs
@@ -15,10 +15,7 @@ namespace Jellyfin.Extensions.Json.Converters
/// <inheritdoc />
public override TStruct? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- // Token is empty string.
- if (reader.TokenType == JsonTokenType.String
- && ((reader.HasValueSequence && reader.ValueSequence.IsEmpty)
- || (!reader.HasValueSequence && reader.ValueSpan.IsEmpty)))
+ if (reader.IsEmptyString())
{
return null;
}
diff --git a/src/Jellyfin.Extensions/Json/JsonDefaults.cs b/src/Jellyfin.Extensions/Json/JsonDefaults.cs
index 9e6d4c3f8..cbe5849ec 100644
--- a/src/Jellyfin.Extensions/Json/JsonDefaults.cs
+++ b/src/Jellyfin.Extensions/Json/JsonDefaults.cs
@@ -38,6 +38,7 @@ namespace Jellyfin.Extensions.Json
new JsonNullableGuidConverter(),
new JsonVersionConverter(),
new JsonFlagEnumConverterFactory(),
+ new JsonDefaultStringEnumConverterFactory(),
new JsonStringEnumConverter(),
new JsonNullableStructConverterFactory(),
new JsonDateTimeConverter(),
diff --git a/src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs b/src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs
new file mode 100644
index 000000000..d06508a26
--- /dev/null
+++ b/src/Jellyfin.Extensions/Json/Utf8JsonExtensions.cs
@@ -0,0 +1,27 @@
+using System.Text.Json;
+
+namespace Jellyfin.Extensions.Json;
+
+/// <summary>
+/// Extensions for Utf8JsonReader and Utf8JsonWriter.
+/// </summary>
+public static class Utf8JsonExtensions
+{
+ /// <summary>
+ /// Determines if the reader contains an empty string.
+ /// </summary>
+ /// <param name="reader">The reader.</param>
+ /// <returns>Whether the reader contains an empty string.</returns>
+ public static bool IsEmptyString(this Utf8JsonReader reader)
+ => reader.TokenType == JsonTokenType.String
+ && ((reader.HasValueSequence && reader.ValueSequence.IsEmpty)
+ || (!reader.HasValueSequence && reader.ValueSpan.IsEmpty));
+
+ /// <summary>
+ /// Determines if the reader contains a null value.
+ /// </summary>
+ /// <param name="reader">The reader.</param>
+ /// <returns>Whether the reader contains null.</returns>
+ public static bool IsNull(this Utf8JsonReader reader)
+ => reader.TokenType == JsonTokenType.Null;
+}
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/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 39f334184..000000000
--- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
+++ /dev/null
@@ -1,2540 +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 AsyncKeyedLock;
-using Jellyfin.Data.Enums;
-using Jellyfin.Data.Events;
-using Jellyfin.Extensions;
-using Jellyfin.LiveTv.Configuration;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.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 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 LiveTvDtoService _tvDtoService;
- private readonly IListingsProvider[] _listingsProviders;
-
- 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 AsyncNonKeyedLocker _recordingDeleteSemaphore = new(1);
-
- private bool _disposed;
-
- public EmbyTV(
- IStreamHelper streamHelper,
- IMediaSourceManager mediaSourceManager,
- ILogger<EmbyTV> logger,
- IHttpClientFactory httpClientFactory,
- IServerConfigurationManager config,
- ITunerHostManager tunerHostManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager,
- ILibraryMonitor libraryMonitor,
- IProviderManager providerManager,
- IMediaEncoder mediaEncoder,
- LiveTvDtoService tvDtoService,
- IEnumerable<IListingsProvider> listingsProviders)
- {
- Current = this;
-
- _logger = logger;
- _httpClientFactory = httpClientFactory;
- _config = config;
- _fileSystem = fileSystem;
- _libraryManager = libraryManager;
- _libraryMonitor = libraryMonitor;
- _providerManager = providerManager;
- _mediaEncoder = mediaEncoder;
- _tvDtoService = tvDtoService;
- _tunerHostManager = tunerHostManager;
- _mediaSourceManager = mediaSourceManager;
- _streamHelper = streamHelper;
- _listingsProviders = listingsProviders.ToArray();
-
- _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json"));
- _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json"));
- _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 Progress<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 = _listingsProviders.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 BaseItem GetLiveTvChannel(TimerInfo timer)
- {
- var internalChannelId = _tvDtoService.GetInternalChannelId(Name, timer.ChannelId);
- return _libraryManager.GetItemById(internalChannelId);
- }
-
- private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo)
- {
- ArgumentNullException.ThrowIfNull(timer);
-
- 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 = GetLiveTvChannel(timer);
-
- 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;
- }
-
- using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false))
- {
- 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");
- }
- }
- }
- }
-
- 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 = [_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;
- _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 = [_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;
- }
-
- /// <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/LiveTvHost.cs b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs
deleted file mode 100644
index dc15d53ff..000000000
--- a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.LiveTv;
-using Microsoft.Extensions.Hosting;
-
-namespace Jellyfin.LiveTv.EmbyTV;
-
-/// <summary>
-/// <see cref="IHostedService"/> responsible for initializing Live TV.
-/// </summary>
-public sealed class LiveTvHost : IHostedService
-{
- private readonly EmbyTV _service;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="LiveTvHost"/> class.
- /// </summary>
- /// <param name="services">The available <see cref="ILiveTvService"/>s.</param>
- public LiveTvHost(IEnumerable<ILiveTvService> services)
- {
- _service = services.OfType<EmbyTV>().First();
- }
-
- /// <inheritdoc />
- public Task StartAsync(CancellationToken cancellationToken) => _service.Start();
-
- /// <inheritdoc />
- public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
-}
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 a07325ad1..73729c950 100644
--- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
+++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
@@ -1,6 +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;
@@ -22,13 +25,19 @@ 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, EmbyTV.EmbyTV>();
+ services.AddSingleton<ILiveTvService, DefaultLiveTvService>();
services.AddSingleton<ITunerHost, HdHomerunHost>();
services.AddSingleton<ITunerHost, M3UTunerHost>();
services.AddSingleton<IListingsProvider, SchedulesDirect>();
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
index 394fbbaea..093970c38 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -27,6 +27,8 @@ public class GuideManager : IGuideManager
private const string EtagKey = "ProgramEtag";
private const string ExternalServiceTag = "ExternalServiceId";
+ private static readonly ParallelOptions _cacheParallelOptions = new() { MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, 10) };
+
private readonly ILogger<GuideManager> _logger;
private readonly IConfigurationManager _config;
private readonly IFileSystem _fileSystem;
@@ -34,6 +36,7 @@ public class GuideManager : IGuideManager
private readonly ILibraryManager _libraryManager;
private readonly ILiveTvManager _liveTvManager;
private readonly ITunerHostManager _tunerHostManager;
+ private readonly IRecordingsManager _recordingsManager;
private readonly LiveTvDtoService _tvDtoService;
/// <summary>
@@ -46,6 +49,7 @@ public class GuideManager : IGuideManager
/// <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,
@@ -55,6 +59,7 @@ public class GuideManager : IGuideManager
ILibraryManager libraryManager,
ILiveTvManager liveTvManager,
ITunerHostManager tunerHostManager,
+ IRecordingsManager recordingsManager,
LiveTvDtoService tvDtoService)
{
_logger = logger;
@@ -64,6 +69,7 @@ public class GuideManager : IGuideManager
_libraryManager = libraryManager;
_liveTvManager = liveTvManager;
_tunerHostManager = tunerHostManager;
+ _recordingsManager = recordingsManager;
_tvDtoService = tvDtoService;
}
@@ -85,7 +91,7 @@ public class GuideManager : IGuideManager
{
ArgumentNullException.ThrowIfNull(progress);
- await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false);
+ await _recordingsManager.CreateRecordingFolders().ConfigureAwait(false);
await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false);
@@ -137,7 +143,7 @@ public class GuideManager : IGuideManager
CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken);
}
- var coreService = _liveTvManager.Services.OfType<EmbyTV.EmbyTV>().FirstOrDefault();
+ var coreService = _liveTvManager.Services.OfType<DefaultLiveTvService>().FirstOrDefault();
if (coreService is not null)
{
await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false);
@@ -205,6 +211,7 @@ public class GuideManager : IGuideManager
_logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays);
+ var maxCacheDate = DateTime.UtcNow.AddDays(2);
foreach (var currentChannel in list)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -259,6 +266,7 @@ public class GuideManager : IGuideManager
if (newPrograms.Count > 0)
{
_libraryManager.CreateItems(newPrograms, null, cancellationToken);
+ await PrecacheImages(newPrograms, maxCacheDate).ConfigureAwait(false);
}
if (updatedPrograms.Count > 0)
@@ -268,6 +276,7 @@ public class GuideManager : IGuideManager
currentChannel,
ItemUpdateType.MetadataImport,
cancellationToken).ConfigureAwait(false);
+ await PrecacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false);
}
currentChannel.IsMovie = isMovie;
@@ -704,4 +713,41 @@ public class GuideManager : IGuideManager
return (item, isNew, isUpdated);
}
+
+ private async Task PrecacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
+ {
+ await Parallel.ForEachAsync(
+ programs
+ .Where(p => p.EndDate.HasValue && p.EndDate.Value < maxCacheDate)
+ .DistinctBy(p => p.Id),
+ _cacheParallelOptions,
+ async (program, cancellationToken) =>
+ {
+ for (var i = 0; i < program.ImageInfos.Length; i++)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ var imageInfo = program.ImageInfos[i];
+ if (!imageInfo.IsLocalFile)
+ {
+ try
+ {
+ program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal(
+ program,
+ imageInfo,
+ imageIndex: 0,
+ removeOnFailure: false)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Unable to precache {Url}", imageInfo.Path);
+ }
+ }
+ }
+ }).ConfigureAwait(false);
+ }
}
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/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/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index ef5283b98..c19d8195c 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -6,14 +6,12 @@ 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.LiveTv.Configuration;
-using Jellyfin.LiveTv.Guide;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration;
@@ -27,7 +25,6 @@ using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.LiveTv
@@ -43,12 +40,11 @@ namespace Jellyfin.LiveTv
private readonly IDtoService _dtoService;
private readonly IUserDataManager _userDataManager;
private readonly ILibraryManager _libraryManager;
- private readonly ITaskManager _taskManager;
private readonly ILocalizationManager _localization;
private readonly IChannelManager _channelManager;
+ private readonly IRecordingsManager _recordingsManager;
private readonly LiveTvDtoService _tvDtoService;
private readonly ILiveTvService[] _services;
- private readonly IListingsProvider[] _listingProviders;
public LiveTvManager(
IServerConfigurationManager config,
@@ -57,27 +53,25 @@ namespace Jellyfin.LiveTv
IDtoService dtoService,
IUserManager userManager,
ILibraryManager libraryManager,
- ITaskManager taskManager,
ILocalizationManager localization,
IChannelManager channelManager,
+ IRecordingsManager recordingsManager,
LiveTvDtoService liveTvDtoService,
- IEnumerable<ILiveTvService> services,
- IEnumerable<IListingsProvider> listingProviders)
+ IEnumerable<ILiveTvService> services)
{
_config = config;
_logger = logger;
_userManager = userManager;
_libraryManager = libraryManager;
- _taskManager = taskManager;
_localization = localization;
_dtoService = dtoService;
_userDataManager = userDataManager;
_channelManager = channelManager;
_tvDtoService = liveTvDtoService;
+ _recordingsManager = recordingsManager;
_services = services.ToArray();
- _listingProviders = listingProviders.ToArray();
- var defaultService = _services.OfType<EmbyTV.EmbyTV>().First();
+ var defaultService = _services.OfType<DefaultLiveTvService>().First();
defaultService.TimerCreated += OnEmbyTvTimerCreated;
defaultService.TimerCancelled += OnEmbyTvTimerCancelled;
}
@@ -96,13 +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);
- }
-
private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs<string> e)
{
var timerId = e.Argument;
@@ -164,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;
@@ -252,127 +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;
- }
- }
- }
- }
-
public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null)
{
var program = _libraryManager.GetItemById(id);
@@ -775,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
@@ -1022,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)));
}
@@ -1331,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)
@@ -1465,168 +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);
- }
-
/// <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 226d525e7..e63afa626 100644
--- a/src/Jellyfin.LiveTv/RecordingNotifier.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs
@@ -11,7 +11,7 @@ using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.LiveTv
+namespace Jellyfin.LiveTv.Recordings
{
/// <summary>
/// <see cref="IHostedService"/> responsible for notifying users when a LiveTV recording is completed.
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/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/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonDefaultStringEnumConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonDefaultStringEnumConverterTests.cs
new file mode 100644
index 000000000..4fd9fd290
--- /dev/null
+++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonDefaultStringEnumConverterTests.cs
@@ -0,0 +1,112 @@
+using System.Text.Json;
+using Jellyfin.Data.Enums;
+using Jellyfin.Extensions.Json.Converters;
+using Xunit;
+
+namespace Jellyfin.Extensions.Tests.Json.Converters;
+
+public class JsonDefaultStringEnumConverterTests
+{
+ private readonly JsonSerializerOptions _jsonOptions = new() { Converters = { new JsonDefaultStringEnumConverterFactory() } };
+
+ /// <summary>
+ /// Test to ensure that `null` and empty string are deserialized to the default value.
+ /// </summary>
+ /// <param name="input">The input string.</param>
+ /// <param name="output">The expected enum value.</param>
+ [Theory]
+ [InlineData("\"\"", MediaStreamProtocol.Http)]
+ [InlineData("\"Http\"", MediaStreamProtocol.Http)]
+ [InlineData("\"Hls\"", MediaStreamProtocol.Hls)]
+ public void Deserialize_Enum_Direct(string input, MediaStreamProtocol output)
+ {
+ var value = JsonSerializer.Deserialize<MediaStreamProtocol>(input, _jsonOptions);
+ Assert.Equal(output, value);
+ }
+
+ /// <summary>
+ /// Test to ensure that `null` and empty string are deserialized to the default value.
+ /// </summary>
+ /// <param name="input">The input string.</param>
+ /// <param name="output">The expected enum value.</param>
+ [Theory]
+ [InlineData(null, MediaStreamProtocol.Http)]
+ [InlineData("\"\"", MediaStreamProtocol.Http)]
+ [InlineData("\"Http\"", MediaStreamProtocol.Http)]
+ [InlineData("\"Hls\"", MediaStreamProtocol.Hls)]
+ public void Deserialize_Enum(string? input, MediaStreamProtocol output)
+ {
+ input ??= "null";
+ var json = $"{{ \"EnumValue\": {input} }}";
+ var value = JsonSerializer.Deserialize<TestClass>(json, _jsonOptions);
+ Assert.NotNull(value);
+ Assert.Equal(output, value.EnumValue);
+ }
+
+ /// <summary>
+ /// Test to ensure that empty string is deserialized to the default value,
+ /// and `null` is deserialized to `null`.
+ /// </summary>
+ /// <param name="input">The input string.</param>
+ /// <param name="output">The expected enum value.</param>
+ [Theory]
+ [InlineData(null, null)]
+ [InlineData("\"\"", MediaStreamProtocol.Http)]
+ [InlineData("\"Http\"", MediaStreamProtocol.Http)]
+ [InlineData("\"Hls\"", MediaStreamProtocol.Hls)]
+ public void Deserialize_Enum_Nullable(string? input, MediaStreamProtocol? output)
+ {
+ input ??= "null";
+ var json = $"{{ \"EnumValue\": {input} }}";
+ var value = JsonSerializer.Deserialize<NullTestClass>(json, _jsonOptions);
+ Assert.NotNull(value);
+ Assert.Equal(output, value.EnumValue);
+ }
+
+ /// <summary>
+ /// Ensures that the roundtrip serialization & deserialization is successful.
+ /// </summary>
+ /// <param name="input">Input enum.</param>
+ /// <param name="output">Output enum.</param>
+ [Theory]
+ [InlineData(MediaStreamProtocol.Http, MediaStreamProtocol.Http)]
+ [InlineData(MediaStreamProtocol.Hls, MediaStreamProtocol.Hls)]
+ public void Enum_RoundTrip(MediaStreamProtocol input, MediaStreamProtocol output)
+ {
+ var inputObj = new TestClass { EnumValue = input };
+
+ var outputObj = JsonSerializer.Deserialize<TestClass>(JsonSerializer.Serialize(inputObj, _jsonOptions), _jsonOptions);
+
+ Assert.NotNull(outputObj);
+ Assert.Equal(output, outputObj.EnumValue);
+ }
+
+ /// <summary>
+ /// Ensures that the roundtrip serialization & deserialization is successful, including null.
+ /// </summary>
+ /// <param name="input">Input enum.</param>
+ /// <param name="output">Output enum.</param>
+ [Theory]
+ [InlineData(MediaStreamProtocol.Http, MediaStreamProtocol.Http)]
+ [InlineData(MediaStreamProtocol.Hls, MediaStreamProtocol.Hls)]
+ [InlineData(null, null)]
+ public void Enum_RoundTrip_Nullable(MediaStreamProtocol? input, MediaStreamProtocol? output)
+ {
+ var inputObj = new NullTestClass { EnumValue = input };
+
+ var outputObj = JsonSerializer.Deserialize<NullTestClass>(JsonSerializer.Serialize(inputObj, _jsonOptions), _jsonOptions);
+
+ Assert.NotNull(outputObj);
+ Assert.Equal(output, outputObj.EnumValue);
+ }
+
+ private sealed class TestClass
+ {
+ public MediaStreamProtocol EnumValue { get; set; }
+ }
+
+ private sealed class NullTestClass
+ {
+ public MediaStreamProtocol? EnumValue { get; set; }
+ }
+}
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.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index d9dceee55..183997fdb 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Runtime.Serialization;
using System.Text.Json;
using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
@@ -21,23 +22,25 @@ namespace Jellyfin.Model.Tests
[Theory]
// Chrome
[InlineData("Chrome", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450
- [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
+ [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
[InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450
[InlineData("Chrome", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")]
- [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")]
+ [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")]
+ [InlineData("Chrome", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")]
[InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
[InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// Firefox
[InlineData("Firefox", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450
- [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
+ [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Firefox", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
[InlineData("Firefox", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450
[InlineData("Firefox", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")]
- [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")]
+ [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")]
+ [InlineData("Firefox", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")]
[InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
[InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
@@ -59,18 +62,20 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
// Yatse
[InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
+ [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
[InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)]
[InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
+ [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("Yatse", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
// RokuSSPlus
[InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 should be DirectPlay
- [InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
+ [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 should be DirectPlay
[InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("RokuSSPlus", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
+ [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("RokuSSPlus", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
// JellyfinMediaPlayer
[InlineData("JellyfinMediaPlayer", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
@@ -83,21 +88,24 @@ namespace Jellyfin.Model.Tests
[InlineData("JellyfinMediaPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] // #6450
// Chrome-NoHLS
[InlineData("Chrome-NoHLS", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450
- [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
+ [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome-NoHLS", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
[InlineData("Chrome-NoHLS", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450
[InlineData("Chrome-NoHLS", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome-NoHLS", "mp4-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", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "http")]
+ [InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "http")]
[InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
[InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome-NoHLS", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// TranscodeMedia
[InlineData("TranscodeMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
- [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
+ [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
+ [InlineData("TranscodeMedia", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
[InlineData("TranscodeMedia", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
[InlineData("TranscodeMedia", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
- [InlineData("TranscodeMedia", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
+ [InlineData("TranscodeMedia", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
+ [InlineData("TranscodeMedia", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
[InlineData("TranscodeMedia", "mkv-av1-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")]
[InlineData("TranscodeMedia", "mkv-av1-vorbis-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "http")]
[InlineData("TranscodeMedia", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")]
@@ -381,21 +389,21 @@ namespace Jellyfin.Model.Tests
// Assert.Equal("webm", val.Container);
Assert.Equal(streamInfo.Container, uri.Extension);
Assert.Equal("stream", uri.Filename);
- Assert.Equal("http", streamInfo.SubProtocol);
+ Assert.Equal(MediaStreamProtocol.Http, streamInfo.SubProtocol);
}
else if (transcodeProtocol.Equals("HLS.mp4", StringComparison.Ordinal))
{
Assert.Equal("mp4", streamInfo.Container);
Assert.Equal("m3u8", uri.Extension);
Assert.Equal("master", uri.Filename);
- Assert.Equal("hls", streamInfo.SubProtocol);
+ Assert.Equal(MediaStreamProtocol.Hls, streamInfo.SubProtocol);
}
else
{
Assert.Equal("ts", streamInfo.Container);
Assert.Equal("m3u8", uri.Extension);
Assert.Equal("master", uri.Filename);
- Assert.Equal("hls", streamInfo.SubProtocol);
+ Assert.Equal(MediaStreamProtocol.Hls, streamInfo.SubProtocol);
}
// Full transcode
@@ -459,8 +467,16 @@ namespace Jellyfin.Model.Tests
// Audio stream not specified
else
{
- // TODO: Fixme
- Assert.All(audioStreams, stream =>
+ bool isDefault = targetAudioStream?.IsDefault == true;
+ var language = targetAudioStream?.Language;
+
+ // Collect candidate audio streams
+ var candidateAudioStreams = audioStreams.Where(stream =>
+ {
+ return isDefault ? stream.IsDefault : (stream.Language == language);
+ });
+
+ Assert.All(candidateAudioStreams, stream =>
{
if (!stream.IsExternal)
{
@@ -473,7 +489,7 @@ namespace Jellyfin.Model.Tests
}
else if (playMethod is null)
{
- Assert.Null(streamInfo.SubProtocol);
+ Assert.Equal(MediaStreamProtocol.Http, streamInfo.SubProtocol);
Assert.Equal("stream", uri.Filename);
Assert.False(streamInfo.EstimateContentLength);
diff --git a/tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs
index 198ad5a27..2399a10a3 100644
--- a/tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs
+++ b/tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs
@@ -27,7 +27,7 @@ public static class ImageFormatExtensionsTests
[InlineData((ImageFormat)int.MinValue)]
[InlineData((ImageFormat)int.MaxValue)]
[InlineData((ImageFormat)(-1))]
- [InlineData((ImageFormat)5)]
+ [InlineData((ImageFormat)6)]
public static void GetMimeType_Valid_ThrowsInvalidEnumArgumentException(ImageFormat format)
=> Assert.Throws<InvalidEnumArgumentException>(() => format.GetMimeType());
@@ -40,7 +40,7 @@ public static class ImageFormatExtensionsTests
[InlineData((ImageFormat)int.MinValue)]
[InlineData((ImageFormat)int.MaxValue)]
[InlineData((ImageFormat)(-1))]
- [InlineData((ImageFormat)5)]
+ [InlineData((ImageFormat)6)]
public static void GetExtension_Valid_ThrowsInvalidEnumArgumentException(ImageFormat format)
=> Assert.Throws<InvalidEnumArgumentException>(() => format.GetExtension());
}
diff --git a/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs
index ccdf01758..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")]
@@ -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-mp4-h264-ac3-aac-aac-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-aac-srt-2600k.json
index 9d819c4ad..ede1c8d3d 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-aac-srt-2600k.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-aac-srt-2600k.json
@@ -57,7 +57,7 @@
"BitRate": 164741,
"Channels": 2,
"SampleRate": 48000,
- "IsDefault": true,
+ "IsDefault": false,
"Profile": "LC",
"Index": 2,
"Score": 203
diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-srt-2600k.json
index 70bbb9d0d..7ef224957 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-srt-2600k.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-srt-2600k.json
@@ -57,7 +57,7 @@
"BitRate": 164741,
"Channels": 2,
"SampleRate": 48000,
- "IsDefault": true,
+ "IsDefault": false,
"Profile": "LC",
"Index": 2,
"Score": 203
diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-ac3-aac-srt-15200k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-ac3-aac-srt-15200k.json
index 385bb7260..84ed8b52c 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-ac3-aac-srt-15200k.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-ac3-aac-srt-15200k.json
@@ -59,7 +59,7 @@
"BitRate": 164741,
"Channels": 2,
"SampleRate": 48000,
- "IsDefault": true,
+ "IsDefault": false,
"Profile": "LC",
"Index": 2,
"Score": 203
diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-ac3-aacDef-srt-15200k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-ac3-aacDef-srt-15200k.json
new file mode 100644
index 000000000..f653313b2
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-ac3-aacDef-srt-15200k.json
@@ -0,0 +1,89 @@
+{
+ "Id": "f6eab7118618ab26e61e495a1853481a",
+ "Path": "/Media/MyVideo-WEBDL-2160p.mp4",
+ "Container": "mov,mp4,m4a,3gp,3g2,mj2",
+ "Size": 6521110016,
+ "Name": "MyVideo WEBDL-2160p",
+ "ETag": "a2fb84b618ba2467fe377543f879e9bf",
+ "RunTimeTicks": 34318510080,
+ "SupportsTranscoding": true,
+ "SupportsDirectStream": true,
+ "SupportsDirectPlay": true,
+ "SupportsProbing": true,
+ "MediaStreams": [
+ {
+ "Codec": "hevc",
+ "CodecTag": "hev1",
+ "Language": "eng",
+ "ColorSpace": "bt2020nc",
+ "ColorTransfer": "smpte2084",
+ "ColorPrimaries": "bt2020",
+ "TimeBase": "1/16000",
+ "VideoRange": "HDR",
+ "DisplayTitle": "4K HEVC HDR",
+ "BitRate": 14715079,
+ "BitDepth": 8,
+ "RefFrames": 1,
+ "IsDefault": true,
+ "Height": 2160,
+ "Width": 3840,
+ "AverageFrameRate": 23.976,
+ "RealFrameRate": 23.976,
+ "Profile": "Main 10",
+ "Type": 1,
+ "AspectRatio": "16:9",
+ "PixelFormat": "yuv420p10le",
+ "Level": 150
+ },
+ {
+ "Codec": "ac3",
+ "CodecTag": "ac-3",
+ "Language": "eng",
+ "TimeBase": "1/48000",
+ "DisplayTitle": "En - Dolby Digital - 5.1 - Default",
+ "ChannelLayout": "5.1",
+ "BitRate": 384000,
+ "Channels": 6,
+ "SampleRate": 48000,
+ "IsDefault": true,
+ "Index": 1,
+ "Score": 202
+ },
+ {
+ "Codec": "aac",
+ "CodecTag": "mp4a",
+ "Language": "eng",
+ "TimeBase": "1/48000",
+ "DisplayTitle": "En - AAC - Stereo - Default",
+ "ChannelLayout": "stereo",
+ "BitRate": 164741,
+ "Channels": 2,
+ "SampleRate": 48000,
+ "IsDefault": true,
+ "Profile": "LC",
+ "Index": 2,
+ "Score": 203
+ },
+ {
+ "Codec": "srt",
+ "Language": "eng",
+ "TimeBase": "1/1000000",
+ "localizedUndefined": "Undefined",
+ "localizedDefault": "Default",
+ "localizedForced": "Forced",
+ "DisplayTitle": "En - Default",
+ "BitRate": 92,
+ "IsDefault": true,
+ "Type": 2,
+ "Index": 3,
+ "Score": 6421,
+ "IsExternal": true,
+ "IsTextSubtitleStream": true,
+ "SupportsExternalStream": true,
+ "Path": "/Media/MyVideo-WEBDL-2160p.default.eng.srt"
+ }
+ ],
+ "Bitrate": 15201382,
+ "DefaultAudioStreamIndex": 2,
+ "DefaultSubtitleStreamIndex": 3
+}
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
index 1e0851993..6fccce049 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
@@ -11,11 +11,13 @@ 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;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
+using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -570,7 +572,9 @@ namespace Jellyfin.Providers.Tests.Manager
Mock.Of<IFileSystem>(),
Mock.Of<IServerApplicationPaths>(),
libraryManager.Object,
- baseItemManager!);
+ baseItemManager!,
+ Mock.Of<ILyricManager>(),
+ Mock.Of<IMemoryCache>());
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>();
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest.json
index 57367ce88..57367ce88 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json
+++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest.json
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
index 5caf7d124..f58a3276b 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
@@ -50,7 +50,7 @@ namespace Jellyfin.Server.Implementations.Tests.Updates
{
PackageInfo[] packages = await _installationManager.GetPackages(
"Jellyfin Stable",
- "https://repo.jellyfin.org/releases/plugin/manifest-stable.json",
+ "https://repo.jellyfin.org/files/plugin/manifest.json",
false);
Assert.Equal(25, packages.Length);
@@ -61,7 +61,7 @@ namespace Jellyfin.Server.Implementations.Tests.Updates
{
PackageInfo[] packages = await _installationManager.GetPackages(
"Jellyfin Stable",
- "https://repo.jellyfin.org/releases/plugin/manifest-stable.json",
+ "https://repo.jellyfin.org/files/plugin/manifest.json",
false);
packages = _installationManager.FilterPackages(packages, "Anime").ToArray();
@@ -73,7 +73,7 @@ namespace Jellyfin.Server.Implementations.Tests.Updates
{
PackageInfo[] packages = await _installationManager.GetPackages(
"Jellyfin Stable",
- "https://repo.jellyfin.org/releases/plugin/manifest-stable.json",
+ "https://repo.jellyfin.org/files/plugin/manifest.json",
false);
packages = _installationManager.FilterPackages(packages, id: new Guid("a4df60c5-6ab4-412a-8f79-2cab93fb2bc5")).ToArray();