aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.config/dotnet-tools.json2
-rw-r--r--.github/workflows/ci-codeql-analysis.yml6
-rw-r--r--CONTRIBUTORS.md1
-rw-r--r--Directory.Packages.props30
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs2
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs8
-rw-r--r--Emby.Server.Implementations/Localization/Core/et.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/ga.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/mk.json3
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs1
-rw-r--r--Jellyfin.Api/Controllers/DisplayPreferencesController.cs15
-rw-r--r--Jellyfin.Api/Controllers/LiveTvController.cs59
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs3
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs7
-rw-r--r--Jellyfin.Api/Controllers/VideosController.cs6
-rw-r--r--Jellyfin.Api/Helpers/DynamicHlsHelper.cs15
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs21
-rw-r--r--Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs2
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs3
-rw-r--r--Jellyfin.Server/Jellyfin.Server.csproj5
-rw-r--r--Jellyfin.Server/Jellyfin.Server.icobin0 -> 40883 bytes
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs4
-rw-r--r--MediaBrowser.Controller/LiveTv/IListingsManager.cs79
-rw-r--r--MediaBrowser.Controller/LiveTv/ILiveTvManager.cs35
-rw-r--r--MediaBrowser.Controller/LiveTv/IRecordingsManager.cs55
-rw-r--r--MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs17
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs5
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs46
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs15
-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/Net/MimeTypes.cs11
-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--deployment/Dockerfile.centos.amd642
-rw-r--r--deployment/Dockerfile.fedora.amd642
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs1688
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs20
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs24
-rw-r--r--src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs9
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs6
-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.cs190
-rw-r--r--src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs6
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs838
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs502
-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)35
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs10
-rw-r--r--tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs11
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json2
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json2
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json2
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs2
65 files changed, 2317 insertions, 2055 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 81fe5add4..d9b689bb6 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "8.0.1",
+ "version": "8.0.2",
"commands": [
"dotnet-ef"
]
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 839bebb96..275dc6f3e 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@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
+ uses: github/codeql-action/init@e2e140ad1441662206e8f97754b166877dfa1c73 # v3.24.4
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
+ uses: github/codeql-action/autobuild@e2e140ad1441662206e8f97754b166877dfa1c73 # v3.24.4
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
+ uses: github/codeql-action/analyze@e2e140ad1441662206e8f97754b166877dfa1c73 # v3.24.4
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..1d7ebfaf4 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.2" />
<PackageVersion Include="FsCheck.Xunit" Version="2.16.6" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.1" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.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" />
@@ -78,14 +78,14 @@
<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.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..d372277e0 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -47,6 +47,7 @@ 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;
@@ -62,6 +63,7 @@ namespace Emby.Server.Implementations.Dto
IItemRepository itemRepo,
IImageProcessor imageProcessor,
IProviderManager providerManager,
+ IRecordingsManager recordingsManager,
IApplicationHost appHost,
IMediaSourceManager mediaSourceManager,
Lazy<ILiveTvManager> livetvManagerFactory,
@@ -74,6 +76,7 @@ namespace Emby.Server.Implementations.Dto
_itemRepo = itemRepo;
_imageProcessor = imageProcessor;
_providerManager = providerManager;
+ _recordingsManager = recordingsManager;
_appHost = appHost;
_mediaSourceManager = mediaSourceManager;
_livetvManagerFactory = livetvManagerFactory;
@@ -256,8 +259,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 +272,7 @@ namespace Emby.Server.Implementations.Dto
dto.Name = dto.SeriesName;
}
- liveTvManager.AddInfoToRecordingDto(item, dto, activeRecording, user);
+ LivetvManager.AddInfoToRecordingDto(item, dto, activeRecording, user);
}
return dto;
diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json
index c78ffa28c..977307b06 100644
--- a/Emby.Server.Implementations/Localization/Core/et.json
+++ b/Emby.Server.Implementations/Localization/Core/et.json
@@ -31,7 +31,7 @@
"VersionNumber": "Versioon {0}",
"ValueSpecialEpisodeName": "Eriepisood - {0}",
"ValueHasBeenAddedToLibrary": "{0} lisati meediakogusse",
- "UserStartedPlayingItemWithValues": "{0} taasesitab {1} serveris {2}",
+ "UserStartedPlayingItemWithValues": "{0} taasesitab {1} seadmes {2}",
"UserPasswordChangedWithName": "Kasutaja {0} parool muudeti",
"UserLockedOutWithName": "Kasutaja {0} lukustati",
"UserDeletedWithName": "Kasutaja {0} kustutati",
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/mk.json b/Emby.Server.Implementations/Localization/Core/mk.json
index cbccad87f..7ef907918 100644
--- a/Emby.Server.Implementations/Localization/Core/mk.json
+++ b/Emby.Server.Implementations/Localization/Core/mk.json
@@ -122,5 +122,6 @@
"TaskRefreshChapterImagesDescription": "Создава тамбнеил за видеата шти имаат поглавја.",
"TaskCleanActivityLogDescription": "Избришува логови на активности постари од определеното време.",
"TaskCleanActivityLog": "Избриши Лог на Активности",
- "External": "Надворешен"
+ "External": "Надворешен",
+ "HearingImpaired": "Оштетен слух"
}
diff --git a/Emby.Server.Implementations/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/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/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/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/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs
index e6c319869..b3029d6fa 100644
--- a/Jellyfin.Api/Controllers/VideosController.cs
+++ b/Jellyfin.Api/Controllers/VideosController.cs
@@ -458,10 +458,8 @@ public class VideosController : BaseJellyfinApiController
return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically");
}
- var outputPath = state.OutputFilePath;
-
// Static stream
- if (@static.HasValue && @static.Value)
+ if (@static.HasValue && @static.Value && !(state.MediaSource.VideoType == VideoType.BluRay || state.MediaSource.VideoType == VideoType.Dvd))
{
var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
@@ -478,7 +476,7 @@ public class VideosController : BaseJellyfinApiController
// Need to start ffmpeg (because media can't be returned directly)
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
- var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast");
+ var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, "superfast");
return await FileStreamResponseHelpers.GetTranscodedFile(
state,
isHeadRequest,
diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
index b0c17c835..f8d89119a 100644
--- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
+++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
@@ -211,19 +211,8 @@ public class DynamicHlsHelper
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
sdrVideoUrl += "&AllowVideoStreamCopy=false";
- var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
- var sdrOutputAudioBitrate = 0;
- if (EncodingHelper.LosslessAudioCodecs.Contains(state.VideoRequest.AudioCodec, StringComparison.OrdinalIgnoreCase))
- {
- sdrOutputAudioBitrate = state.AudioStream.BitRate ?? 0;
- }
- else
- {
- sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream, state.OutputAudioChannels) ?? 0;
- }
-
- var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate;
- AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup);
+ // HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
+ AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
// Restore the video codec
state.OutputVideoCodec = "copy";
diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs
index 7a3842a9f..bfe71fd87 100644
--- a/Jellyfin.Api/Helpers/StreamingHelpers.cs
+++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs
@@ -225,7 +225,7 @@ public static class StreamingHelpers
var ext = string.IsNullOrWhiteSpace(state.OutputContainer)
? GetOutputFileExtension(state, mediaSource)
- : ("." + state.OutputContainer);
+ : ("." + GetContainerFileExtension(state.OutputContainer));
state.OutputFilePath = GetOutputFilePath(state, ext, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId);
@@ -559,4 +559,23 @@ public static class StreamingHelpers
}
}
}
+
+ /// <summary>
+ /// Parses the container into its file extension.
+ /// </summary>
+ /// <param name="container">The container.</param>
+ private static string? GetContainerFileExtension(string? container)
+ {
+ if (string.Equals(container, "mpegts", StringComparison.OrdinalIgnoreCase))
+ {
+ return "ts";
+ }
+
+ if (string.Equals(container, "matroska", StringComparison.OrdinalIgnoreCase))
+ {
+ return "mkv";
+ }
+
+ return container;
+ }
}
diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
index 6a30de5e6..8482b1cf1 100644
--- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
+++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
@@ -22,7 +22,7 @@ public class GetProgramsDto
/// <summary>
/// Gets or sets optional. Filter by user id.
/// </summary>
- public Guid UserId { get; set; }
+ public Guid? UserId { get; set; }
/// <summary>
/// Gets or sets the minimum premiere start date.
diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
index f6854157a..095bc9ed3 100644
--- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
+++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
@@ -382,7 +382,7 @@ public class TrickplayManager : ITrickplayManager
if (trickplayInfo.ThumbnailCount > 0)
{
- const string urlFormat = "Trickplay/{0}/{1}.jpg?MediaSourceId={2}&api_key={3}";
+ const string urlFormat = "{0}.jpg?MediaSourceId={1}&api_key={2}";
const string decimalFormat = "{0:0.###}";
var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}";
@@ -431,7 +431,6 @@ public class TrickplayManager : ITrickplayManager
.AppendFormat(
CultureInfo.InvariantCulture,
urlFormat,
- width.ToString(CultureInfo.InvariantCulture),
i.ToString(CultureInfo.InvariantCulture),
itemId.ToString("N"),
apiKey)
diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj
index 21c6e6f01..e18212908 100644
--- a/Jellyfin.Server/Jellyfin.Server.csproj
+++ b/Jellyfin.Server/Jellyfin.Server.csproj
@@ -12,6 +12,7 @@
<ServerGarbageCollection>false</ServerGarbageCollection>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <ApplicationIcon>Jellyfin.Server.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
@@ -19,6 +20,10 @@
</ItemGroup>
<ItemGroup>
+ <Content Include="Jellyfin.Server.ico" />
+ </ItemGroup>
+
+ <ItemGroup>
<EmbeddedResource Include="Resources/Configuration/*" />
</ItemGroup>
diff --git a/Jellyfin.Server/Jellyfin.Server.ico b/Jellyfin.Server/Jellyfin.Server.ico
new file mode 100644
index 000000000..0872b956a
--- /dev/null
+++ b/Jellyfin.Server/Jellyfin.Server.ico
Binary files differ
diff --git a/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/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..ed08cdc47 100644
--- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
+++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
@@ -36,8 +36,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>
@@ -240,31 +238,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 +245,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/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 1c95192f1..b6738e7cc 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -2988,7 +2988,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);
}
@@ -6541,13 +6541,14 @@ namespace MediaBrowser.Controller.MediaEncoding
return " -codec:s:0 " + codec + " -disposition:s:0 default";
}
- public string GetProgressiveVideoFullCommandLine(EncodingJobInfo state, EncodingOptions encodingOptions, string outputPath, string defaultPreset)
+ public string GetProgressiveVideoFullCommandLine(EncodingJobInfo state, EncodingOptions encodingOptions, string defaultPreset)
{
// Get the output codec name
var videoCodec = GetVideoEncoder(state, encodingOptions);
var format = string.Empty;
var keyFrame = string.Empty;
+ var outputPath = state.OutputFilePath;
if (Path.GetExtension(outputPath.AsSpan()).Equals(".mp4", StringComparison.OrdinalIgnoreCase)
&& state.BaseRequest.Context == EncodingContext.Streaming)
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index f86d14fc8..cc6971c1b 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -1111,6 +1111,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
return allVobs
.Where(vob => titles.Contains(_fileSystem.GetFileNameWithoutExtension(vob).AsSpan().RightPart('_').ToString()))
.Select(i => i.FullName)
+ .Order()
.ToList();
}
@@ -1127,6 +1128,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
return directoryFiles
.Where(f => validPlaybackFiles.Contains(f.Name, StringComparer.OrdinalIgnoreCase))
.Select(f => f.FullName)
+ .Order()
.ToList();
}
@@ -1150,31 +1152,29 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
// Generate concat configuration entries for each file and write to file
- using (StreamWriter sw = new StreamWriter(concatFilePath))
+ using StreamWriter sw = new StreamWriter(concatFilePath);
+ foreach (var path in files)
{
- foreach (var path in files)
- {
- var mediaInfoResult = GetMediaInfo(
- new MediaInfoRequest
+ var mediaInfoResult = GetMediaInfo(
+ new MediaInfoRequest
+ {
+ MediaType = DlnaProfileType.Video,
+ MediaSource = new MediaSourceInfo
{
- MediaType = DlnaProfileType.Video,
- MediaSource = new MediaSourceInfo
- {
- Path = path,
- Protocol = MediaProtocol.File,
- VideoType = videoType
- }
- },
- CancellationToken.None).GetAwaiter().GetResult();
-
- var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds;
-
- // Add file path stanza to concat configuration
- sw.WriteLine("file '{0}'", path);
-
- // Add duration stanza to concat configuration
- sw.WriteLine("duration {0}", duration);
- }
+ Path = path,
+ Protocol = MediaProtocol.File,
+ VideoType = videoType
+ }
+ },
+ CancellationToken.None).GetAwaiter().GetResult();
+
+ var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds;
+
+ // Add file path stanza to concat configuration
+ sw.WriteLine("file '{0}'", path);
+
+ // Add duration stanza to concat configuration
+ sw.WriteLine("duration {0}", duration);
}
}
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index ab3eb3298..146b30643 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -405,7 +405,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId);
if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding))
{
- this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
+ OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
throw new ArgumentException("User does not have access to video transcoding.");
}
@@ -417,7 +417,12 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
{
var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
- if (state.VideoType != VideoType.Dvd)
+ if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay)
+ {
+ var concatPath = Path.Join(_serverConfigurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat");
+ await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
+ }
+ else
{
await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
}
@@ -432,7 +437,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
}
}
- var process = new Process
+ using var process = new Process
{
StartInfo = new ProcessStartInfo
{
@@ -452,7 +457,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
EnableRaisingEvents = true
};
- var transcodingJob = this.OnTranscodeBeginning(
+ var transcodingJob = OnTranscodeBeginning(
outputPath,
state.Request.PlaySessionId,
state.MediaSource.LiveStreamId,
@@ -507,7 +512,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
catch (Exception ex)
{
_logger.LogError(ex, "Error starting FFmpeg");
- this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
+ OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
throw;
}
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.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/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/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.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
index 39f334184..06a0ea4e9 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
+++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
@@ -3,267 +3,77 @@
#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 Jellyfin.LiveTv.Timers;
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 sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds
{
- public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss";
+ public const string ServiceName = "Emby";
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 IListingsManager _listingsManager;
+ private readonly IRecordingsManager _recordingsManager;
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;
+ private readonly TimerManager _timerManager;
+ private readonly SeriesTimerManager _seriesTimerManager;
public EmbyTV(
- IStreamHelper streamHelper,
- IMediaSourceManager mediaSourceManager,
ILogger<EmbyTV> logger,
- IHttpClientFactory httpClientFactory,
IServerConfigurationManager config,
ITunerHostManager tunerHostManager,
- IFileSystem fileSystem,
+ IListingsManager listingsManager,
+ IRecordingsManager recordingsManager,
ILibraryManager libraryManager,
- ILibraryMonitor libraryMonitor,
- IProviderManager providerManager,
- IMediaEncoder mediaEncoder,
LiveTvDtoService tvDtoService,
- IEnumerable<IListingsProvider> listingsProviders)
+ TimerManager timerManager,
+ SeriesTimerManager seriesTimerManager)
{
- 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;
+ _listingsManager = listingsManager;
+ _recordingsManager = recordingsManager;
+ _tvDtoService = tvDtoService;
+ _timerManager = timerManager;
+ _seriesTimerManager = seriesTimerManager;
- _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
+ _timerManager.TimerFired += OnTimerManagerTimerFired;
}
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");
+ public string Name => ServiceName;
/// <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);
@@ -282,9 +92,9 @@ namespace Jellyfin.LiveTv.EmbyTV
foreach (var timer in timers)
{
- if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id))
+ if (DateTime.UtcNow > timer.EndDate && _recordingsManager.GetActiveRecordingPath(timer.Id) is null)
{
- OnTimerOutOfDate(timer);
+ _timerManager.Delete(timer);
continue;
}
@@ -296,31 +106,26 @@ namespace Jellyfin.LiveTv.EmbyTV
var program = GetProgramInfoFromCache(timer);
if (program is null)
{
- OnTimerOutOfDate(timer);
+ _timerManager.Delete(timer);
continue;
}
CopyProgramInfoToTimerInfo(program, timer, tempChannelCache);
- _timerProvider.Update(timer);
+ _timerManager.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>();
+ var channels = new List<ChannelInfo>();
foreach (var hostInstance in _tunerHostManager.TunerHosts)
{
try
{
- var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false);
+ var tunerChannels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false);
- list.AddRange(channels);
+ channels.AddRange(tunerChannels);
}
catch (Exception ex)
{
@@ -328,209 +133,9 @@ namespace Jellyfin.LiveTv.EmbyTV
}
}
- 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;
- }
+ await _listingsManager.AddProviderMetadata(channels, enableCache, cancellationToken).ConfigureAwait(false);
- 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();
+ return channels;
}
public Task<IEnumerable<ChannelInfo>> GetChannelsAsync(CancellationToken cancellationToken)
@@ -540,7 +145,7 @@ namespace Jellyfin.LiveTv.EmbyTV
public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken)
{
- var timers = _timerProvider
+ var timers = _timerManager
.GetAll()
.Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase))
.ToList();
@@ -550,10 +155,10 @@ namespace Jellyfin.LiveTv.EmbyTV
CancelTimerInternal(timer.Id, true, true);
}
- var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase));
+ var remove = _seriesTimerManager.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase));
if (remove is not null)
{
- _seriesTimerProvider.Delete(remove);
+ _seriesTimerManager.Delete(remove);
}
return Task.CompletedTask;
@@ -561,7 +166,7 @@ namespace Jellyfin.LiveTv.EmbyTV
private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation)
{
- var timer = _timerProvider.GetTimer(timerId);
+ var timer = _timerManager.GetTimer(timerId);
if (timer is not null)
{
var statusChanging = timer.Status != RecordingStatus.Cancelled;
@@ -574,11 +179,11 @@ namespace Jellyfin.LiveTv.EmbyTV
if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled)
{
- _timerProvider.Delete(timer);
+ _timerManager.Delete(timer);
}
else
{
- _timerProvider.AddOrUpdate(timer, false);
+ _timerManager.AddOrUpdate(timer, false);
}
if (statusChanging && TimerCancelled is not null)
@@ -587,11 +192,7 @@ namespace Jellyfin.LiveTv.EmbyTV
}
}
- if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo))
- {
- activeRecordingInfo.Timer = timer;
- activeRecordingInfo.CancellationTokenSource.Cancel();
- }
+ _recordingsManager.CancelRecording(timerId, timer);
}
public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken)
@@ -614,7 +215,7 @@ namespace Jellyfin.LiveTv.EmbyTV
{
var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ?
null :
- _timerProvider.GetTimerByProgramId(info.ProgramId);
+ _timerManager.GetTimerByProgramId(info.ProgramId);
if (existingTimer is not null)
{
@@ -623,7 +224,7 @@ namespace Jellyfin.LiveTv.EmbyTV
{
existingTimer.Status = RecordingStatus.New;
existingTimer.IsManual = true;
- _timerProvider.Update(existingTimer);
+ _timerManager.Update(existingTimer);
return Task.FromResult(existingTimer.Id);
}
@@ -651,7 +252,7 @@ namespace Jellyfin.LiveTv.EmbyTV
}
info.IsManual = true;
- _timerProvider.Add(info);
+ _timerManager.Add(info);
TimerCreated?.Invoke(this, new GenericEventArgs<TimerInfo>(info));
@@ -692,14 +293,14 @@ namespace Jellyfin.LiveTv.EmbyTV
})
.ToList();
- _seriesTimerProvider.Add(info);
+ _seriesTimerManager.Add(info);
foreach (var timer in existingTimers)
{
timer.SeriesTimerId = info.Id;
timer.IsManual = true;
- _timerProvider.AddOrUpdate(timer, false);
+ _timerManager.AddOrUpdate(timer, false);
}
UpdateTimersForSeriesTimer(info, true, false);
@@ -709,7 +310,7 @@ namespace Jellyfin.LiveTv.EmbyTV
public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken)
{
- var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+ var instance = _seriesTimerManager.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
if (instance is not null)
{
@@ -729,7 +330,7 @@ namespace Jellyfin.LiveTv.EmbyTV
instance.KeepUntil = info.KeepUntil;
instance.StartDate = info.StartDate;
- _seriesTimerProvider.Update(instance);
+ _seriesTimerManager.Update(instance);
UpdateTimersForSeriesTimer(instance, true, true);
}
@@ -739,7 +340,7 @@ namespace Jellyfin.LiveTv.EmbyTV
public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken)
{
- var existingTimer = _timerProvider.GetTimer(updatedTimer.Id);
+ var existingTimer = _timerManager.GetTimer(updatedTimer.Id);
if (existingTimer is null)
{
@@ -747,14 +348,14 @@ namespace Jellyfin.LiveTv.EmbyTV
}
// Only update if not currently active
- if (!_activeRecordings.TryGetValue(updatedTimer.Id, out _))
+ if (_recordingsManager.GetActiveRecordingPath(updatedTimer.Id) is null)
{
existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds;
existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds;
existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired;
existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired;
- _timerProvider.Update(existingTimer);
+ _timerManager.Update(existingTimer);
}
return Task.CompletedTask;
@@ -787,40 +388,6 @@ namespace Jellyfin.LiveTv.EmbyTV
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>
@@ -828,7 +395,7 @@ namespace Jellyfin.LiveTv.EmbyTV
RecordingStatus.Completed
};
- var timers = _timerProvider.GetAll()
+ var timers = _timerManager.GetAll()
.Where(i => !excludeStatues.Contains(i.Status));
return Task.FromResult(timers);
@@ -874,22 +441,7 @@ namespace Jellyfin.LiveTv.EmbyTV
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);
+ return Task.FromResult((IEnumerable<SeriesTimerInfo>)_seriesTimerManager.GetAll());
}
public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
@@ -897,55 +449,8 @@ namespace Jellyfin.LiveTv.EmbyTV
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();
+ return await _listingsManager.GetProgramsAsync(channel, startDateUtc, endDateUtc, cancellationToken)
+ .ConfigureAwait(false);
}
public Task<MediaSourceInfo> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken)
@@ -1031,7 +536,7 @@ namespace Jellyfin.LiveTv.EmbyTV
return Task.CompletedTask;
}
- private async void OnTimerProviderTimerFired(object sender, GenericEventArgs<TimerInfo> e)
+ private async void OnTimerManagerTimerFired(object sender, GenericEventArgs<TimerInfo> e)
{
var timer = e.Argument;
@@ -1040,11 +545,10 @@ namespace Jellyfin.LiveTv.EmbyTV
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);
+ _timerManager.Delete(timer);
return;
}
@@ -1055,133 +559,39 @@ namespace Jellyfin.LiveTv.EmbyTV
Id = timer.Id
};
- if (!_activeRecordings.ContainsKey(timer.Id))
- {
- await RecordStream(timer, recordingEndDate, activeRecordingInfo).ConfigureAwait(false);
- }
- else
+ if (_recordingsManager.GetActiveRecordingPath(timer.Id) is not null)
{
_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");
+ return;
}
- var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
- if (timer.ProductionYear.HasValue)
+ LiveTvProgram programInfo = null;
+ if (!string.IsNullOrWhiteSpace(timer.ProgramId))
{
- folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
+ programInfo = GetProgramInfoFromCache(timer);
}
- // trim trailing period from the folder name
- folderName = folderName.TrimEnd('.').Trim();
-
- recordPath = Path.Combine(recordPath, folderName);
- }
- else if (timer.IsKids)
- {
- if (config.EnableRecordingSubfolders)
+ if (programInfo is null)
{
- recordPath = Path.Combine(recordPath, "Kids");
+ _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId);
+ programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate);
}
- var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
- if (timer.ProductionYear.HasValue)
+ if (programInfo is not null)
{
- folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
+ CopyProgramInfoToTimerInfo(programInfo, timer);
}
- // trim trailing period from the folder name
- folderName = folderName.TrimEnd('.').Trim();
-
- recordPath = Path.Combine(recordPath, folderName);
+ await _recordingsManager.RecordStream(activeRecordingInfo, GetLiveTvChannel(timer), recordingEndDate)
+ .ConfigureAwait(false);
}
- else if (timer.IsSports)
+ catch (OperationCanceledException)
{
- if (config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "Sports");
- }
-
- recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim());
}
- else
+ catch (Exception ex)
{
- if (config.EnableRecordingSubfolders)
- {
- recordPath = Path.Combine(recordPath, "Other");
- }
-
- recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim());
+ _logger.LogError(ex, "Error recording stream");
}
-
- var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts";
-
- return Path.Combine(recordPath, recordingFileName);
}
private BaseItem GetLiveTvChannel(TimerInfo timer)
@@ -1190,904 +600,6 @@ namespace Jellyfin.LiveTv.EmbyTV
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
@@ -2161,7 +673,7 @@ namespace Jellyfin.LiveTv.EmbyTV
foreach (var timer in timers.OrderByDescending(t => GetLiveTvChannel(t).IsHD).ThenBy(t => t.StartDate).Skip(1))
{
timer.Status = RecordingStatus.Cancelled;
- _timerProvider.Update(timer);
+ _timerManager.Update(timer);
}
}
@@ -2200,10 +712,10 @@ namespace Jellyfin.LiveTv.EmbyTV
var enabledTimersForSeries = new List<TimerInfo>();
foreach (var timer in allTimers)
{
- var existingTimer = _timerProvider.GetTimer(timer.Id)
+ var existingTimer = _timerManager.GetTimer(timer.Id)
?? (string.IsNullOrWhiteSpace(timer.ProgramId)
? null
- : _timerProvider.GetTimerByProgramId(timer.ProgramId));
+ : _timerManager.GetTimerByProgramId(timer.ProgramId));
if (existingTimer is null)
{
@@ -2216,14 +728,15 @@ namespace Jellyfin.LiveTv.EmbyTV
enabledTimersForSeries.Add(timer);
}
- _timerProvider.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 (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _))
+ else if (_recordingsManager.GetActiveRecordingPath(timer.Id) is null
+ && _recordingsManager.GetActiveRecordingPath(existingTimer.Id) is null)
{
UpdateExistingTimerWithNewMetadata(existingTimer, timer);
@@ -2256,7 +769,7 @@ namespace Jellyfin.LiveTv.EmbyTV
}
existingTimer.SeriesTimerId = seriesTimer.Id;
- _timerProvider.Update(existingTimer);
+ _timerManager.Update(existingTimer);
}
}
@@ -2273,7 +786,7 @@ namespace Jellyfin.LiveTv.EmbyTV
RecordingStatus.New
};
- var deletes = _timerProvider.GetAll()
+ 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))
@@ -2481,60 +994,5 @@ namespace Jellyfin.LiveTv.EmbyTV
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
index dc15d53ff..18ff6a949 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs
+++ b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs
@@ -1,7 +1,6 @@
-using System.Collections.Generic;
-using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.LiveTv.Timers;
using MediaBrowser.Controller.LiveTv;
using Microsoft.Extensions.Hosting;
@@ -12,19 +11,26 @@ namespace Jellyfin.LiveTv.EmbyTV;
/// </summary>
public sealed class LiveTvHost : IHostedService
{
- private readonly EmbyTV _service;
+ private readonly IRecordingsManager _recordingsManager;
+ private readonly TimerManager _timerManager;
/// <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)
+ /// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
+ /// <param name="timerManager">The <see cref="TimerManager"/>.</param>
+ public LiveTvHost(IRecordingsManager recordingsManager, TimerManager timerManager)
{
- _service = services.OfType<EmbyTV>().First();
+ _recordingsManager = recordingsManager;
+ _timerManager = timerManager;
}
/// <inheritdoc />
- public Task StartAsync(CancellationToken cancellationToken) => _service.Start();
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _timerManager.RestartTimers();
+ return _recordingsManager.CreateRecordingFolders();
+ }
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
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..e247ecb44 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,11 +25,17 @@ 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<ITunerHost, HdHomerunHost>();
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
index 394fbbaea..056bb6e6d 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -34,6 +34,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 +47,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 +57,7 @@ public class GuideManager : IGuideManager
ILibraryManager libraryManager,
ILiveTvManager liveTvManager,
ITunerHostManager tunerHostManager,
+ IRecordingsManager recordingsManager,
LiveTvDtoService tvDtoService)
{
_logger = logger;
@@ -64,6 +67,7 @@ public class GuideManager : IGuideManager
_libraryManager = libraryManager;
_liveTvManager = liveTvManager;
_tunerHostManager = tunerHostManager;
+ _recordingsManager = recordingsManager;
_tvDtoService = tvDtoService;
}
@@ -85,7 +89,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);
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..f7b9604af 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -6,14 +6,13 @@ 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 Jellyfin.LiveTv.IO;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration;
@@ -27,7 +26,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 +41,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,25 +54,23 @@ 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();
defaultService.TimerCreated += OnEmbyTvTimerCreated;
@@ -96,13 +91,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;
@@ -775,18 +763,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(EmbyTV.EmbyTV.ServiceName, info.ChannelId));
dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId)
? null
@@ -1465,168 +1448,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..c6874e4db 100644
--- a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
+++ b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
@@ -24,13 +24,15 @@ namespace Jellyfin.LiveTv
private const char StreamIdDelimiter = '_';
private readonly ILiveTvManager _liveTvManager;
+ private readonly IRecordingsManager _recordingsManager;
private readonly ILogger<LiveTvMediaSourceProvider> _logger;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IServerApplicationHost _appHost;
- public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, ILogger<LiveTvMediaSourceProvider> logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost)
+ public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, IRecordingsManager recordingsManager, ILogger<LiveTvMediaSourceProvider> logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost)
{
_liveTvManager = liveTvManager;
+ _recordingsManager = recordingsManager;
_logger = logger;
_mediaSourceManager = mediaSourceManager;
_appHost = appHost;
@@ -40,7 +42,7 @@ namespace Jellyfin.LiveTv
{
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)
{
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
new file mode 100644
index 000000000..20f89ec8f
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
@@ -0,0 +1,838 @@
+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.EmbyTV;
+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..0a71a4d46
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
@@ -0,0 +1,502 @@
+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 Jellyfin.LiveTv.EmbyTV;
+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..6bcbd3324 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.EmbyTV;
+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,22 +86,11 @@ 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;
}
@@ -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.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index 2f84fa544..d9dceee55 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -28,7 +28,7 @@ namespace Jellyfin.Model.Tests
[InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")]
[InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")]
[InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
- [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// Firefox
[InlineData("Firefox", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450
@@ -39,7 +39,7 @@ namespace Jellyfin.Model.Tests
[InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")]
[InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")]
[InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
- [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// Safari
[InlineData("SafariNext", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450
@@ -90,7 +90,7 @@ namespace Jellyfin.Model.Tests
[InlineData("Chrome-NoHLS", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")]
[InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "http")]
[InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
- [InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome-NoHLS", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// TranscodeMedia
[InlineData("TranscodeMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
@@ -178,7 +178,7 @@ namespace Jellyfin.Model.Tests
[InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")]
[InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")]
[InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
- [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// Firefox
[InlineData("Firefox", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450
@@ -188,7 +188,7 @@ namespace Jellyfin.Model.Tests
[InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")]
[InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")]
[InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450
- [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450
+ [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
// Safari
[InlineData("SafariNext", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450
diff --git a/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs
index 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-mkv-av1-aac-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json
index da185aacf..e528281bd 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-av1-aac-srt-2600k.json
@@ -1,7 +1,7 @@
{
"Id": "a766d122b58e45d9492d17af66748bf5",
"Path": "/Media/MyVideo-720p.mkv",
- "Container": "mkv,webm",
+ "Container": "mkv",
"Size": 835317696,
"Name": "MyVideo-1080p",
"ETag": "579a34c6d5dfb23f61539a51220b6a23",
diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json
index 0a85a1353..8ef10ae87 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-aac-srt-2600k.json
@@ -1,7 +1,7 @@
{
"Id": "a766d122b58e45d9492d17af66748bf5",
"Path": "/Media/MyVideo-720p.mkv",
- "Container": "mkv,webm",
+ "Container": "mkv",
"Size": 835317696,
"Name": "MyVideo-1080p",
"ETag": "579a34c6d5dfb23f61539a51220b6a23",
diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json
index 2b932ff52..80a9f4103 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-vp9-ac3-srt-2600k.json
@@ -1,7 +1,7 @@
{
"Id": "a766d122b58e45d9492d17af66748bf5",
"Path": "/Media/MyVideo-720p.mkv",
- "Container": "mkv,webm",
+ "Container": "mkv",
"Size": 835317696,
"Name": "MyVideo-1080p",
"ETag": "579a34c6d5dfb23f61539a51220b6a23",
diff --git a/tests/Jellyfin.Providers.Tests/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>();