From 259fe4522c4fdcd7467500780fc70ccd75108bf2 Mon Sep 17 00:00:00 2001 From: LJQ Date: Tue, 17 Oct 2023 17:25:41 +0800 Subject: Update /Device endpoint to return CustomName --- Jellyfin.Server.Implementations/Devices/DeviceManager.cs | 10 +++++++++- MediaBrowser.Model/Devices/DeviceInfo.cs | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index a4b4c19599..80abe01abc 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -177,7 +177,7 @@ namespace Jellyfin.Server.Implementations.Devices .OrderByDescending(d => d.DateLastActivity) .ThenBy(d => d.DeviceId) .AsAsyncEnumerable(); - + IAsyncEnumerable deviceOptions = dbContext.DeviceOptions.AsAsyncEnumerable(); if (supportsSync.HasValue) { sessions = sessions.Where(i => GetCapabilities(i.DeviceId).SupportsSync == supportsSync.Value); @@ -195,6 +195,14 @@ namespace Jellyfin.Server.Implementations.Devices } var array = await sessions.Select(device => ToDeviceInfo(device)).ToArrayAsync().ConfigureAwait(false); + await foreach (var deviceOption in deviceOptions) + { + var deviceInfo = array.FirstOrDefault(d => d.Id.Equals(deviceOption.DeviceId, StringComparison.OrdinalIgnoreCase)); + if (deviceInfo != null) + { + deviceInfo.CustomName = deviceOption.CustomName; + } + } return new QueryResult(array); } diff --git a/MediaBrowser.Model/Devices/DeviceInfo.cs b/MediaBrowser.Model/Devices/DeviceInfo.cs index 7a1c7a7382..4962992a0a 100644 --- a/MediaBrowser.Model/Devices/DeviceInfo.cs +++ b/MediaBrowser.Model/Devices/DeviceInfo.cs @@ -15,6 +15,8 @@ namespace MediaBrowser.Model.Devices public string Name { get; set; } + public string CustomName { get; set; } + /// /// Gets or sets the access token. /// -- cgit v1.2.3 From 2f9b44fcd04bf7974d42b6db3f9e7e500ae8093c Mon Sep 17 00:00:00 2001 From: LJQ Date: Wed, 18 Oct 2023 00:41:33 +0800 Subject: Switch to LINQ and updated /Device/Info endpoint to return in accordance to the updated API doc --- .../Devices/DeviceManager.cs | 46 +++++++++++++--------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index 80abe01abc..807d703eb7 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -110,21 +110,21 @@ namespace Jellyfin.Server.Implementations.Devices /// public async Task GetDevice(string id) { - Device? device; var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - device = await dbContext.Devices + var device = await dbContext.Devices .Where(d => d.DeviceId == id) .OrderByDescending(d => d.DateLastActivity) .Include(d => d.User) + .SelectMany(d => dbContext.DeviceOptions.Where(o => o.DeviceId == d.DeviceId).DefaultIfEmpty(), (d, o) => new { Device = d, Options = o }) .FirstOrDefaultAsync() .ConfigureAwait(false); - } - var deviceInfo = device is null ? null : ToDeviceInfo(device); + var deviceInfo = device is null ? null : ToDeviceInfo(device.Device, device.Options); - return deviceInfo; + return deviceInfo; + } } /// @@ -172,15 +172,15 @@ namespace Jellyfin.Server.Implementations.Devices var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - IAsyncEnumerable sessions = dbContext.Devices + var sessions = dbContext.Devices .Include(d => d.User) .OrderByDescending(d => d.DateLastActivity) .ThenBy(d => d.DeviceId) + .SelectMany(d => dbContext.DeviceOptions.Where(o => o.DeviceId == d.DeviceId).DefaultIfEmpty(), (d, o) => new { Device = d, Options = o }) .AsAsyncEnumerable(); - IAsyncEnumerable deviceOptions = dbContext.DeviceOptions.AsAsyncEnumerable(); if (supportsSync.HasValue) { - sessions = sessions.Where(i => GetCapabilities(i.DeviceId).SupportsSync == supportsSync.Value); + sessions = sessions.Where(i => GetCapabilities(i.Device.DeviceId).SupportsSync == supportsSync.Value); } if (userId.HasValue) @@ -191,18 +191,10 @@ namespace Jellyfin.Server.Implementations.Devices throw new ResourceNotFoundException(); } - sessions = sessions.Where(i => CanAccessDevice(user, i.DeviceId)); + sessions = sessions.Where(i => CanAccessDevice(user, i.Device.DeviceId)); } - var array = await sessions.Select(device => ToDeviceInfo(device)).ToArrayAsync().ConfigureAwait(false); - await foreach (var deviceOption in deviceOptions) - { - var deviceInfo = array.FirstOrDefault(d => d.Id.Equals(deviceOption.DeviceId, StringComparison.OrdinalIgnoreCase)); - if (deviceInfo != null) - { - deviceInfo.CustomName = deviceOption.CustomName; - } - } + var array = await sessions.Select(device => ToDeviceInfo(device.Device, device.Options)).ToArrayAsync().ConfigureAwait(false); return new QueryResult(array); } @@ -250,5 +242,23 @@ namespace Jellyfin.Server.Implementations.Devices IconUrl = caps.IconUrl }; } + + private DeviceInfo ToDeviceInfo(Device authInfo, DeviceOptions? options) + { + var caps = GetCapabilities(authInfo.DeviceId); + + return new DeviceInfo + { + AppName = authInfo.AppName, + AppVersion = authInfo.AppVersion, + Id = authInfo.DeviceId, + LastUserId = authInfo.UserId, + LastUserName = authInfo.User.Username, + Name = authInfo.DeviceName, + DateLastActivity = authInfo.DateLastActivity, + IconUrl = caps.IconUrl, + CustomName = options?.CustomName, + }; + } } } -- cgit v1.2.3 From 98bc2fea8b20a52edfd142fbbf2ffbf79b0608b0 Mon Sep 17 00:00:00 2001 From: LJQ Date: Wed, 18 Oct 2023 01:40:36 +0800 Subject: Removed dupe ToDeviceInfo --- .../Devices/DeviceManager.cs | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index 807d703eb7..d8eee12467 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -226,24 +226,7 @@ namespace Jellyfin.Server.Implementations.Devices || !GetCapabilities(deviceId).SupportsPersistentIdentifier; } - private DeviceInfo ToDeviceInfo(Device authInfo) - { - var caps = GetCapabilities(authInfo.DeviceId); - - return new DeviceInfo - { - AppName = authInfo.AppName, - AppVersion = authInfo.AppVersion, - Id = authInfo.DeviceId, - LastUserId = authInfo.UserId, - LastUserName = authInfo.User.Username, - Name = authInfo.DeviceName, - DateLastActivity = authInfo.DateLastActivity, - IconUrl = caps.IconUrl - }; - } - - private DeviceInfo ToDeviceInfo(Device authInfo, DeviceOptions? options) + private DeviceInfo ToDeviceInfo(Device authInfo, DeviceOptions? options = null) { var caps = GetCapabilities(authInfo.DeviceId); -- cgit v1.2.3 From bce22fcc2a50d533e4d69760ec99cd09c4cb7ad7 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Sun, 12 Nov 2023 17:52:24 +0300 Subject: Added new API endpoint to update User item data --- Jellyfin.Api/Controllers/ItemsController.cs | 106 +++++++++++++++++++++- MediaBrowser.Model/Entities/UserDataSaveReason.cs | 7 +- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 4e46e808ae..38a7d115e5 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Threading; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -34,6 +35,7 @@ public class ItemsController : BaseJellyfinApiController private readonly IDtoService _dtoService; private readonly ILogger _logger; private readonly ISessionManager _sessionManager; + private readonly IUserDataManager _userDataRepository; /// /// Initializes a new instance of the class. @@ -44,13 +46,15 @@ public class ItemsController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public ItemsController( IUserManager userManager, ILibraryManager libraryManager, ILocalizationManager localization, IDtoService dtoService, ILogger logger, - ISessionManager sessionManager) + ISessionManager sessionManager, + IUserDataManager userDataRepository) { _userManager = userManager; _libraryManager = libraryManager; @@ -58,6 +62,7 @@ public class ItemsController : BaseJellyfinApiController _dtoService = dtoService; _logger = logger; _sessionManager = sessionManager; + _userDataRepository = userDataRepository; } /// @@ -881,4 +886,103 @@ public class ItemsController : BaseJellyfinApiController itemsResult.TotalRecordCount, returnItems); } + + /// + /// Get Item User Data. + /// + /// The user id. + /// The item id. + /// item user data returned. + /// When item is not found. + /// Return Item . + [HttpGet("Users/{userId}/Items/{itemId}/UserData")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetItemUserData( + [FromRoute, Required] Guid userId, + [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException(); + var item = _libraryManager.GetItemById(itemId); + + return (item == null) ? NotFound() : _userDataRepository.GetUserDataDto(item, user); + } + + /// + /// Update Item User Data. + /// + /// The user id. + /// The item id. + /// Optional. Whether to mark the item as played. + /// Optional. Whether to mark the item as favorite. + /// Optional. Whether mark the item as liked. + /// Optional. User item rating. + /// Optional. User Item playback Position Ticks. 1 tick = 10000 ms. + /// Item user data. + /// Optional. The date the item was played. + /// update user data returned. + /// When item is not found. + /// . + [HttpPost("Users/{userId}/Items/{itemId}/UserData")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateItemUserData( + [FromRoute, Required] Guid userId, + [FromRoute, Required] Guid itemId, + [FromQuery] bool? played, + [FromQuery] bool? favorite, + [FromQuery] bool? likes, + [FromQuery] double? rating, + [FromQuery] long? playbackPositionTicks, + [FromQuery] int? playCount, + [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? lastPlayedDate) + { + var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException(); + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + var userData = _userDataRepository.GetUserData(user, item); + + if (played.HasValue) + { + userData.Played = played.Value; + } + + if (favorite.HasValue) + { + userData.IsFavorite = favorite.Value; + } + + if (likes.HasValue) + { + userData.Likes = likes.Value; + } + + if (rating.HasValue) + { + userData.Rating = rating.Value; + } + + if (playbackPositionTicks.HasValue) + { + userData.PlaybackPositionTicks = playbackPositionTicks.Value; + } + + if (playCount.HasValue) + { + userData.PlayCount = playCount.Value; + } + + if (lastPlayedDate.HasValue) + { + userData.LastPlayedDate = lastPlayedDate.Value; + } + + _userDataRepository.SaveUserData(user.Id, item, userData, UserDataSaveReason.UpdateUserData, CancellationToken.None); + + return _userDataRepository.GetUserDataDto(item, user); + } } diff --git a/MediaBrowser.Model/Entities/UserDataSaveReason.cs b/MediaBrowser.Model/Entities/UserDataSaveReason.cs index 20404e6f4d..b8e73a98cd 100644 --- a/MediaBrowser.Model/Entities/UserDataSaveReason.cs +++ b/MediaBrowser.Model/Entities/UserDataSaveReason.cs @@ -33,6 +33,11 @@ namespace MediaBrowser.Model.Entities /// /// The import. /// - Import = 6 + Import = 6, + + /// + /// API call updated item user data. + /// + UpdateUserData = 7, } } -- cgit v1.2.3 From 250e795c3be21586f50414efff2a9846588ac864 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Sun, 12 Nov 2023 18:12:34 +0300 Subject: Typo fixes. --- Jellyfin.Api/Controllers/ItemsController.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 38a7d115e5..8c816f8022 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -892,9 +892,9 @@ public class ItemsController : BaseJellyfinApiController /// /// The user id. /// The item id. - /// item user data returned. - /// When item is not found. - /// Return Item . + /// return item user data. + /// Item is not found. + /// Return . [HttpGet("Users/{userId}/Items/{itemId}/UserData")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -915,14 +915,14 @@ public class ItemsController : BaseJellyfinApiController /// The item id. /// Optional. Whether to mark the item as played. /// Optional. Whether to mark the item as favorite. - /// Optional. Whether mark the item as liked. + /// Optional. Whether to mark the item as liked. /// Optional. User item rating. - /// Optional. User Item playback Position Ticks. 1 tick = 10000 ms. - /// Item user data. + /// Optional. Item playback position ticks. 1 tick = 10000 ms. + /// Optional. How many times the user played the item. /// Optional. The date the item was played. - /// update user data returned. - /// When item is not found. - /// . + /// return updated user item data. + /// Item is not found. + /// Return . [HttpPost("Users/{userId}/Items/{itemId}/UserData")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] -- cgit v1.2.3 From 2a25c5a2e3e37e734993d17b7462598babcb0b97 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Mon, 13 Nov 2023 15:51:06 +0300 Subject: Refactored api call logic handling. --- .../Library/UserDataManager.cs | 37 +++++++++++++ Jellyfin.Api/Controllers/ItemsController.cs | 60 +++------------------- .../Library/IUserDataManager.cs | 9 ++++ MediaBrowser.Model/Dto/UserDataDto.cs | 43 ++++++++++++++++ 4 files changed, 97 insertions(+), 52 deletions(-) create mode 100644 MediaBrowser.Model/Dto/UserDataDto.cs diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index a0a90b129f..0d67f2cda3 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; +using System.Reflection; using System.Threading; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Configuration; @@ -81,6 +82,42 @@ namespace Emby.Server.Implementations.Library }); } + public void SaveUserData(User user, BaseItem item, UserDataDto userDataDto, UserDataSaveReason reason) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(reason); + ArgumentNullException.ThrowIfNull(userDataDto); + + var userData = GetUserData(user, item); + + var parentProperties = userDataDto.GetType().GetProperties(); + var childProperties = userData.GetType().GetProperties(); + + foreach (var parentProperty in parentProperties) + { + foreach (var childProperty in childProperties) + { + if (parentProperty.Name != childProperty.Name) + { + continue; + } + + var value = parentProperty.GetValue(userDataDto, null); + + if (value is null) + { + continue; + } + + childProperty.SetValue(userData, value, null); + break; + } + } + + SaveUserData(user, item, userData, reason, CancellationToken.None); + } + /// /// Save the provided user data for the given user. Batch operation. Does not fire any events or update the cache. /// diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 8c816f8022..2a346be685 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -913,13 +913,7 @@ public class ItemsController : BaseJellyfinApiController /// /// The user id. /// The item id. - /// Optional. Whether to mark the item as played. - /// Optional. Whether to mark the item as favorite. - /// Optional. Whether to mark the item as liked. - /// Optional. User item rating. - /// Optional. Item playback position ticks. 1 tick = 10000 ms. - /// Optional. How many times the user played the item. - /// Optional. The date the item was played. + /// New user data object. /// return updated user item data. /// Item is not found. /// Return . @@ -929,14 +923,13 @@ public class ItemsController : BaseJellyfinApiController public ActionResult UpdateItemUserData( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, - [FromQuery] bool? played, - [FromQuery] bool? favorite, - [FromQuery] bool? likes, - [FromQuery] double? rating, - [FromQuery] long? playbackPositionTicks, - [FromQuery] int? playCount, - [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? lastPlayedDate) + [FromBody, Required] UserDataDto userDataDto) { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update this item user data."); + } + var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException(); var item = _libraryManager.GetItemById(itemId); if (item == null) @@ -944,44 +937,7 @@ public class ItemsController : BaseJellyfinApiController return NotFound(); } - var userData = _userDataRepository.GetUserData(user, item); - - if (played.HasValue) - { - userData.Played = played.Value; - } - - if (favorite.HasValue) - { - userData.IsFavorite = favorite.Value; - } - - if (likes.HasValue) - { - userData.Likes = likes.Value; - } - - if (rating.HasValue) - { - userData.Rating = rating.Value; - } - - if (playbackPositionTicks.HasValue) - { - userData.PlaybackPositionTicks = playbackPositionTicks.Value; - } - - if (playCount.HasValue) - { - userData.PlayCount = playCount.Value; - } - - if (lastPlayedDate.HasValue) - { - userData.LastPlayedDate = lastPlayedDate.Value; - } - - _userDataRepository.SaveUserData(user.Id, item, userData, UserDataSaveReason.UpdateUserData, CancellationToken.None); + _userDataRepository.SaveUserData(user, item, userDataDto, UserDataSaveReason.UpdateUserData); return _userDataRepository.GetUserDataDto(item, user); } diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs index 034c405910..8849c098fb 100644 --- a/MediaBrowser.Controller/Library/IUserDataManager.cs +++ b/MediaBrowser.Controller/Library/IUserDataManager.cs @@ -35,6 +35,15 @@ namespace MediaBrowser.Controller.Library void SaveUserData(User user, BaseItem item, UserItemData userData, UserDataSaveReason reason, CancellationToken cancellationToken); + /// + /// Save the provided user data for the given user. + /// + /// The user. + /// The item. + /// The reason for updating the user data. + /// The reason. + void SaveUserData(User user, BaseItem item, UserDataDto userDataDto, UserDataSaveReason reason); + UserItemData GetUserData(User user, BaseItem item); UserItemData GetUserData(Guid userId, BaseItem item); diff --git a/MediaBrowser.Model/Dto/UserDataDto.cs b/MediaBrowser.Model/Dto/UserDataDto.cs new file mode 100644 index 0000000000..3012916f84 --- /dev/null +++ b/MediaBrowser.Model/Dto/UserDataDto.cs @@ -0,0 +1,43 @@ +#nullable disable +using System; + +namespace MediaBrowser.Model.Dto +{ + /// + /// Class UserDataDto extends UserItemDataDto to allow nullable members. + /// This change allow us to implement the new /Users/{UserId}/Items/{ItemId}/UserData endpoint. + /// This object allows the requestor to update all or specific user data fields without altering the non-nullable members state. + /// + public class UserDataDto : UserItemDataDto + { + /// + /// Gets or sets the playback position ticks. + /// + /// The playback position ticks. + public new long? PlaybackPositionTicks { get; set; } + + /// + /// Gets or sets the play count. + /// + /// The play count. + public new int? PlayCount { get; set; } + + /// + /// Gets or sets a value indicating whether this instance is favorite. + /// + /// true if this instance is favorite; otherwise, false. + public new bool? IsFavorite { get; set; } + + /// + /// Gets or sets a value indicating whether this is likes. + /// + /// null if [likes] contains no value, true if [likes]; otherwise, false. + public new bool? Likes { get; set; } + + /// + /// Gets or sets a value indicating whether this is played. + /// + /// true if played; otherwise, false. + public new bool? Played { get; set; } + } +} -- cgit v1.2.3 From faa036aa7b696fd5091e52fd2bed6ed67ca63481 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Mon, 13 Nov 2023 15:55:12 +0300 Subject: Added access validation to view item user data. --- Jellyfin.Api/Controllers/ItemsController.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 2a346be685..dd54e6ca78 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -902,6 +902,11 @@ public class ItemsController : BaseJellyfinApiController [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to view this item user data."); + } + var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException(); var item = _libraryManager.GetItemById(itemId); -- cgit v1.2.3 From 07db2025a177bf5bed1cc898426a2442803ae151 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Mon, 13 Nov 2023 17:32:24 +0300 Subject: Refactored the code to not use reflection. --- .../Library/UserDataManager.cs | 45 ++++++++++++++-------- MediaBrowser.Model/Dto/UserDataDto.cs | 3 -- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 0d67f2cda3..585fdd4f09 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -91,28 +91,39 @@ namespace Emby.Server.Implementations.Library var userData = GetUserData(user, item); - var parentProperties = userDataDto.GetType().GetProperties(); - var childProperties = userData.GetType().GetProperties(); + if (userDataDto.PlaybackPositionTicks.HasValue) + { + userData.PlaybackPositionTicks = userDataDto.PlaybackPositionTicks.Value; + } - foreach (var parentProperty in parentProperties) + if (userDataDto.PlayCount.HasValue) { - foreach (var childProperty in childProperties) - { - if (parentProperty.Name != childProperty.Name) - { - continue; - } + userData.PlayCount = userDataDto.PlayCount.Value; + } - var value = parentProperty.GetValue(userDataDto, null); + if (userDataDto.IsFavorite.HasValue) + { + userData.IsFavorite = userDataDto.IsFavorite.Value; + } - if (value is null) - { - continue; - } + if (userDataDto.Likes.HasValue) + { + userData.Likes = userDataDto.Likes.Value; + } - childProperty.SetValue(userData, value, null); - break; - } + if (userDataDto.Played.HasValue) + { + userData.Played = userDataDto.Played.Value; + } + + if (userDataDto.LastPlayedDate.HasValue) + { + userData.LastPlayedDate = userDataDto.LastPlayedDate.Value; + } + + if (userDataDto.Rating.HasValue) + { + userData.Rating = userDataDto.Rating.Value; } SaveUserData(user, item, userData, reason, CancellationToken.None); diff --git a/MediaBrowser.Model/Dto/UserDataDto.cs b/MediaBrowser.Model/Dto/UserDataDto.cs index 3012916f84..6d8c8969b0 100644 --- a/MediaBrowser.Model/Dto/UserDataDto.cs +++ b/MediaBrowser.Model/Dto/UserDataDto.cs @@ -1,6 +1,3 @@ -#nullable disable -using System; - namespace MediaBrowser.Model.Dto { /// -- cgit v1.2.3 From c4013d2e10c2c526e21dd0229d60ab9e6c51d252 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Wed, 15 Nov 2023 13:55:14 +0300 Subject: Updated the summary and the Dto name. --- .../Library/UserDataManager.cs | 3 +- Jellyfin.Api/Controllers/ItemsController.cs | 3 +- .../Library/IUserDataManager.cs | 2 +- MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs | 38 ++++++++++++++++++++ MediaBrowser.Model/Dto/UserDataDto.cs | 40 ---------------------- 5 files changed, 41 insertions(+), 45 deletions(-) create mode 100644 MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs delete mode 100644 MediaBrowser.Model/Dto/UserDataDto.cs diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 585fdd4f09..8beeb8041f 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; -using System.Reflection; using System.Threading; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Configuration; @@ -82,7 +81,7 @@ namespace Emby.Server.Implementations.Library }); } - public void SaveUserData(User user, BaseItem item, UserDataDto userDataDto, UserDataSaveReason reason) + public void SaveUserData(User user, BaseItem item, UpdateUserItemDataDto userDataDto, UserDataSaveReason reason) { ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(item); diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index dd54e6ca78..ae80d15e60 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -1,7 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using System.Threading; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -928,7 +927,7 @@ public class ItemsController : BaseJellyfinApiController public ActionResult UpdateItemUserData( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, - [FromBody, Required] UserDataDto userDataDto) + [FromBody, Required] UpdateUserItemDataDto userDataDto) { if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) { diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs index 8849c098fb..43cccfc65c 100644 --- a/MediaBrowser.Controller/Library/IUserDataManager.cs +++ b/MediaBrowser.Controller/Library/IUserDataManager.cs @@ -42,7 +42,7 @@ namespace MediaBrowser.Controller.Library /// The item. /// The reason for updating the user data. /// The reason. - void SaveUserData(User user, BaseItem item, UserDataDto userDataDto, UserDataSaveReason reason); + void SaveUserData(User user, BaseItem item, UpdateUserItemDataDto userDataDto, UserDataSaveReason reason); UserItemData GetUserData(User user, BaseItem item); diff --git a/MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs b/MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs new file mode 100644 index 0000000000..9ff09cb22c --- /dev/null +++ b/MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs @@ -0,0 +1,38 @@ +namespace MediaBrowser.Model.Dto +{ + /// + /// This is used by the api to get information about a item user data. + /// + public class UpdateUserItemDataDto : UserItemDataDto + { + /// + /// Gets or sets the playback position ticks. + /// + /// The playback position ticks. + public new long? PlaybackPositionTicks { get; set; } + + /// + /// Gets or sets the play count. + /// + /// The play count. + public new int? PlayCount { get; set; } + + /// + /// Gets or sets a value indicating whether this instance is favorite. + /// + /// true if this instance is favorite; otherwise, false. + public new bool? IsFavorite { get; set; } + + /// + /// Gets or sets a value indicating whether this is likes. + /// + /// null if [likes] contains no value, true if [likes]; otherwise, false. + public new bool? Likes { get; set; } + + /// + /// Gets or sets a value indicating whether this is played. + /// + /// true if played; otherwise, false. + public new bool? Played { get; set; } + } +} diff --git a/MediaBrowser.Model/Dto/UserDataDto.cs b/MediaBrowser.Model/Dto/UserDataDto.cs deleted file mode 100644 index 6d8c8969b0..0000000000 --- a/MediaBrowser.Model/Dto/UserDataDto.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace MediaBrowser.Model.Dto -{ - /// - /// Class UserDataDto extends UserItemDataDto to allow nullable members. - /// This change allow us to implement the new /Users/{UserId}/Items/{ItemId}/UserData endpoint. - /// This object allows the requestor to update all or specific user data fields without altering the non-nullable members state. - /// - public class UserDataDto : UserItemDataDto - { - /// - /// Gets or sets the playback position ticks. - /// - /// The playback position ticks. - public new long? PlaybackPositionTicks { get; set; } - - /// - /// Gets or sets the play count. - /// - /// The play count. - public new int? PlayCount { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is favorite. - /// - /// true if this instance is favorite; otherwise, false. - public new bool? IsFavorite { get; set; } - - /// - /// Gets or sets a value indicating whether this is likes. - /// - /// null if [likes] contains no value, true if [likes]; otherwise, false. - public new bool? Likes { get; set; } - - /// - /// Gets or sets a value indicating whether this is played. - /// - /// true if played; otherwise, false. - public new bool? Played { get; set; } - } -} -- cgit v1.2.3 From 22c90141e7eaed43be399df06354f993636c64a3 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Thu, 23 Nov 2023 12:14:31 +0300 Subject: Made UpdateUserItemDataDto standalone object. --- MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs | 53 +++++++++++++++++++++---- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs b/MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs index 9ff09cb22c..5c89c2470d 100644 --- a/MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs +++ b/MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs @@ -1,38 +1,77 @@ +#nullable disable +using System; + namespace MediaBrowser.Model.Dto { /// /// This is used by the api to get information about a item user data. /// - public class UpdateUserItemDataDto : UserItemDataDto + public class UpdateUserItemDataDto { + /// + /// Gets or sets the rating. + /// + /// The rating. + public double? Rating { get; set; } + + /// + /// Gets or sets the played percentage. + /// + /// The played percentage. + public double? PlayedPercentage { get; set; } + + /// + /// Gets or sets the unplayed item count. + /// + /// The unplayed item count. + public int? UnplayedItemCount { get; set; } + /// /// Gets or sets the playback position ticks. /// /// The playback position ticks. - public new long? PlaybackPositionTicks { get; set; } + public long? PlaybackPositionTicks { get; set; } /// /// Gets or sets the play count. /// /// The play count. - public new int? PlayCount { get; set; } + public int? PlayCount { get; set; } /// /// Gets or sets a value indicating whether this instance is favorite. /// /// true if this instance is favorite; otherwise, false. - public new bool? IsFavorite { get; set; } + public bool? IsFavorite { get; set; } /// - /// Gets or sets a value indicating whether this is likes. + /// Gets or sets a value indicating whether this is likes. /// /// null if [likes] contains no value, true if [likes]; otherwise, false. - public new bool? Likes { get; set; } + public bool? Likes { get; set; } + + /// + /// Gets or sets the last played date. + /// + /// The last played date. + public DateTime? LastPlayedDate { get; set; } /// /// Gets or sets a value indicating whether this is played. /// /// true if played; otherwise, false. - public new bool? Played { get; set; } + public bool? Played { get; set; } + + /// + /// Gets or sets the key. + /// + /// The key. + public string Key { get; set; } + + /// + /// Gets or sets the item identifier. + /// + /// The item identifier. + public string ItemId { get; set; } } } -- cgit v1.2.3 From 324c86e14d9d76670b4e3439c2ed5fcbbfb9f8ff Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Thu, 23 Nov 2023 14:37:37 +0300 Subject: Made key & itemId nullable --- MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs b/MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs index 5c89c2470d..7bfedf9735 100644 --- a/MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs +++ b/MediaBrowser.Model/Dto/UpdateUserItemDataDto.cs @@ -1,4 +1,3 @@ -#nullable disable using System; namespace MediaBrowser.Model.Dto @@ -66,12 +65,12 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the key. /// /// The key. - public string Key { get; set; } + public string? Key { get; set; } /// /// Gets or sets the item identifier. /// /// The item identifier. - public string ItemId { get; set; } + public string? ItemId { get; set; } } } -- cgit v1.2.3 From 59ed6ab8ea1c97938fd832618d916719c27887a0 Mon Sep 17 00:00:00 2001 From: joncrall Date: Sun, 3 Dec 2023 15:58:26 -0500 Subject: Quote bash paths to prevent word splitting --- deployment/build.centos.amd64 | 8 ++++---- deployment/build.debian.amd64 | 8 ++++---- deployment/build.debian.arm64 | 8 ++++---- deployment/build.debian.armhf | 8 ++++---- deployment/build.fedora.amd64 | 8 ++++---- deployment/build.linux.amd64 | 8 ++++---- deployment/build.linux.amd64-musl | 8 ++++---- deployment/build.linux.arm64 | 8 ++++---- deployment/build.linux.armhf | 8 ++++---- deployment/build.linux.musl-linux-arm64 | 8 ++++---- deployment/build.macos.amd64 | 8 ++++---- deployment/build.macos.arm64 | 8 ++++---- deployment/build.portable | 8 ++++---- deployment/build.ubuntu.amd64 | 8 ++++---- deployment/build.ubuntu.arm64 | 8 ++++---- deployment/build.ubuntu.armhf | 8 ++++---- deployment/build.windows.amd64 | 8 ++++---- 17 files changed, 68 insertions(+), 68 deletions(-) diff --git a/deployment/build.centos.amd64 b/deployment/build.centos.amd64 index 0374624d80..a0ab93e4e0 100755 --- a/deployment/build.centos.amd64 +++ b/deployment/build.centos.amd64 @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" if [[ ${IS_DOCKER} == YES ]]; then # Remove BuildRequires for dotnet, since it's installed manually @@ -39,10 +39,10 @@ make -f fedora/Makefile srpm outdir=/root/rpmbuild/SRPMS rpmbuild --rebuild -bb /root/rpmbuild/SRPMS/jellyfin-*.src.rpm # Move the artifacts out -mv /root/rpmbuild/RPMS/x86_64/jellyfin-*.rpm /root/rpmbuild/SRPMS/jellyfin-*.src.rpm ${ARTIFACT_DIR}/ +mv /root/rpmbuild/RPMS/x86_64/jellyfin-*.rpm /root/rpmbuild/SRPMS/jellyfin-*.src.rpm "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi rm -f fedora/jellyfin*.tar.gz @@ -51,7 +51,7 @@ if [[ ${IS_DOCKER} == YES ]]; then pushd fedora cp -a /tmp/spec.orig jellyfin.spec - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" popd fi diff --git a/deployment/build.debian.amd64 b/deployment/build.debian.amd64 index 7e968192bb..1a59d02e91 100755 --- a/deployment/build.debian.amd64 +++ b/deployment/build.debian.amd64 @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" if [[ ${IS_DOCKER} == YES ]]; then # Remove build-dep for dotnet-sdk-8.0, since it's installed manually @@ -32,12 +32,12 @@ fi # Build DEB dpkg-buildpackage -us -uc --pre-clean --post-clean -mkdir -p ${ARTIFACT_DIR}/ -mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.debian.arm64 b/deployment/build.debian.arm64 index 7b7b603d63..e1e30fab4a 100755 --- a/deployment/build.debian.arm64 +++ b/deployment/build.debian.arm64 @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" if [[ ${IS_DOCKER} == YES ]]; then # Remove build-dep for dotnet-sdk-8.0, since it's installed manually @@ -33,12 +33,12 @@ fi export CONFIG_SITE=/etc/dpkg-cross/cross-config.${ARCH} dpkg-buildpackage -us -uc -a arm64 --pre-clean --post-clean -mkdir -p ${ARTIFACT_DIR}/ -mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.debian.armhf b/deployment/build.debian.armhf index 3d894ba20e..e3e8ae004e 100755 --- a/deployment/build.debian.armhf +++ b/deployment/build.debian.armhf @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" if [[ ${IS_DOCKER} == YES ]]; then # Remove build-dep for dotnet-sdk-8.0, since it's installed manually @@ -33,12 +33,12 @@ fi export CONFIG_SITE=/etc/dpkg-cross/cross-config.${ARCH} dpkg-buildpackage -us -uc -a armhf --pre-clean --post-clean -mkdir -p ${ARTIFACT_DIR}/ -mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.fedora.amd64 b/deployment/build.fedora.amd64 index 1b629289f0..da345ec08a 100755 --- a/deployment/build.fedora.amd64 +++ b/deployment/build.fedora.amd64 @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" if [[ ${IS_DOCKER} == YES ]]; then # Remove BuildRequires for dotnet, since it's installed manually @@ -39,10 +39,10 @@ make -f fedora/Makefile srpm outdir=/root/rpmbuild/SRPMS rpmbuild -rb /root/rpmbuild/SRPMS/jellyfin-*.src.rpm # Move the artifacts out -mv /root/rpmbuild/RPMS/x86_64/jellyfin-*.rpm /root/rpmbuild/SRPMS/jellyfin-*.src.rpm ${ARTIFACT_DIR}/ +mv /root/rpmbuild/RPMS/x86_64/jellyfin-*.rpm /root/rpmbuild/SRPMS/jellyfin-*.src.rpm "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi rm -f fedora/jellyfin*.tar.gz @@ -51,7 +51,7 @@ if [[ ${IS_DOCKER} == YES ]]; then pushd fedora cp -a /tmp/spec.orig jellyfin.spec - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" popd fi diff --git a/deployment/build.linux.amd64 b/deployment/build.linux.amd64 index 05059e4edf..c6baa61f6e 100755 --- a/deployment/build.linux.amd64 +++ b/deployment/build.linux.amd64 @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" # Get version if [[ ${IS_UNSTABLE} == 'yes' ]]; then @@ -21,11 +21,11 @@ tar -czf jellyfin-server_${version}_linux-amd64.tar.gz -C dist jellyfin-server_$ rm -rf dist/jellyfin-server_${version} # Move the artifacts out -mkdir -p ${ARTIFACT_DIR}/ -mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.linux.amd64-musl b/deployment/build.linux.amd64-musl index 0ee4b05fb3..6523f8319a 100755 --- a/deployment/build.linux.amd64-musl +++ b/deployment/build.linux.amd64-musl @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" # Get version if [[ ${IS_UNSTABLE} == 'yes' ]]; then @@ -21,11 +21,11 @@ tar -czf jellyfin-server_${version}_linux-amd64-musl.tar.gz -C dist jellyfin-ser rm -rf dist/jellyfin-server_${version} # Move the artifacts out -mkdir -p ${ARTIFACT_DIR}/ -mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.linux.arm64 b/deployment/build.linux.arm64 index 6e36db0eb8..6d6a8f803a 100755 --- a/deployment/build.linux.arm64 +++ b/deployment/build.linux.arm64 @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" # Get version if [[ ${IS_UNSTABLE} == 'yes' ]]; then @@ -21,11 +21,11 @@ tar -czf jellyfin-server_${version}_linux-arm64.tar.gz -C dist jellyfin-server_$ rm -rf dist/jellyfin-server_${version} # Move the artifacts out -mkdir -p ${ARTIFACT_DIR}/ -mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.linux.armhf b/deployment/build.linux.armhf index f83eeebf1c..5167dfcb8d 100755 --- a/deployment/build.linux.armhf +++ b/deployment/build.linux.armhf @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" # Get version if [[ ${IS_UNSTABLE} == 'yes' ]]; then @@ -21,11 +21,11 @@ tar -czf jellyfin-server_${version}_linux-armhf.tar.gz -C dist jellyfin-server_$ rm -rf dist/jellyfin-server_${version} # Move the artifacts out -mkdir -p ${ARTIFACT_DIR}/ -mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.linux.musl-linux-arm64 b/deployment/build.linux.musl-linux-arm64 index 38826ae7fc..57980314d4 100755 --- a/deployment/build.linux.musl-linux-arm64 +++ b/deployment/build.linux.musl-linux-arm64 @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" # Get version if [[ ${IS_UNSTABLE} == 'yes' ]]; then @@ -21,11 +21,11 @@ tar -czf jellyfin-server_${version}_linux-arm64-musl.tar.gz -C dist jellyfin-ser rm -rf dist/jellyfin-server_${version} # Move the artifacts out -mkdir -p ${ARTIFACT_DIR}/ -mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.macos.amd64 b/deployment/build.macos.amd64 index eac3538771..c7711e82c7 100755 --- a/deployment/build.macos.amd64 +++ b/deployment/build.macos.amd64 @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" # Get version if [[ ${IS_UNSTABLE} == 'yes' ]]; then @@ -21,11 +21,11 @@ tar -czf jellyfin-server_${version}_macos-amd64.tar.gz -C dist jellyfin-server_$ rm -rf dist/jellyfin-server_${version} # Move the artifacts out -mkdir -p ${ARTIFACT_DIR}/ -mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.macos.arm64 b/deployment/build.macos.arm64 index 42da07e2f6..b07eaad4e0 100755 --- a/deployment/build.macos.arm64 +++ b/deployment/build.macos.arm64 @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" # Get version if [[ ${IS_UNSTABLE} == 'yes' ]]; then @@ -21,11 +21,11 @@ tar -czf jellyfin-server_${version}_macos-arm64.tar.gz -C dist jellyfin-server_$ rm -rf dist/jellyfin-server_${version} # Move the artifacts out -mkdir -p ${ARTIFACT_DIR}/ -mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.portable b/deployment/build.portable index 27e5e987fe..ec151d2956 100755 --- a/deployment/build.portable +++ b/deployment/build.portable @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" # Get version if [[ ${IS_UNSTABLE} == 'yes' ]]; then @@ -21,11 +21,11 @@ tar -czf jellyfin-server_${version}_portable.tar.gz -C dist jellyfin-server_${ve rm -rf dist/jellyfin-server_${version} # Move the artifacts out -mkdir -p ${ARTIFACT_DIR}/ -mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.ubuntu.amd64 b/deployment/build.ubuntu.amd64 index 5f25cb610f..17968a6e93 100755 --- a/deployment/build.ubuntu.amd64 +++ b/deployment/build.ubuntu.amd64 @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" if [[ ${IS_DOCKER} == YES ]]; then # Remove build-dep for dotnet-sdk-8.0, since it's installed manually @@ -32,12 +32,12 @@ fi # Build DEB dpkg-buildpackage -us -uc --pre-clean --post-clean -mkdir -p ${ARTIFACT_DIR}/ -mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.ubuntu.arm64 b/deployment/build.ubuntu.arm64 index 334ced9970..ee7da9bb98 100755 --- a/deployment/build.ubuntu.arm64 +++ b/deployment/build.ubuntu.arm64 @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" if [[ ${IS_DOCKER} == YES ]]; then # Remove build-dep for dotnet-sdk-8.0, since it's installed manually @@ -33,12 +33,12 @@ fi export CONFIG_SITE=/etc/dpkg-cross/cross-config.${ARCH} dpkg-buildpackage -us -uc -a arm64 --pre-clean --post-clean -mkdir -p ${ARTIFACT_DIR}/ -mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.ubuntu.armhf b/deployment/build.ubuntu.armhf index 77e33c3071..85c993282e 100755 --- a/deployment/build.ubuntu.armhf +++ b/deployment/build.ubuntu.armhf @@ -6,7 +6,7 @@ set -o errexit set -o xtrace # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" if [[ ${IS_DOCKER} == YES ]]; then # Remove build-dep for dotnet-sdk-8.0, since it's installed manually @@ -33,12 +33,12 @@ fi export CONFIG_SITE=/etc/dpkg-cross/cross-config.${ARCH} dpkg-buildpackage -us -uc -a armhf --pre-clean --post-clean -mkdir -p ${ARTIFACT_DIR}/ -mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.windows.amd64 b/deployment/build.windows.amd64 index 0786358bd5..20f976365d 100755 --- a/deployment/build.windows.amd64 +++ b/deployment/build.windows.amd64 @@ -11,7 +11,7 @@ NSSM_URL="http://files.evilt.win/nssm/${NSSM_VERSION}.zip" FFMPEG_URL="https://repo.jellyfin.org/releases/server/windows/ffmpeg/jellyfin-ffmpeg-portable_win64.zip"; # Move to source directory -pushd ${SOURCE_DIR} +pushd "${SOURCE_DIR}" # Get version if [[ ${IS_UNSTABLE} == 'yes' ]]; then @@ -42,11 +42,11 @@ popd rm -rf ${output_dir} # Move the artifacts out -mkdir -p ${ARTIFACT_DIR}/ -mv dist/jellyfin[-_]*.zip ${ARTIFACT_DIR}/ +mkdir -p "${ARTIFACT_DIR}/" +mv dist/jellyfin[-_]*.zip "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" fi popd -- cgit v1.2.3 From 67b4cef77a4b391d5dcf0769d0c70762128ddfd1 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 5 Dec 2023 11:59:51 -0500 Subject: Use implementation types in LiveTv --- Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs | 4 ++-- Emby.Server.Implementations/LiveTv/LiveTvManager.cs | 2 +- Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs index 5369c9b3d1..b548a92b19 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs @@ -34,7 +34,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private readonly IServerConfigurationManager _serverConfigurationManager; private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private bool _hasExited; - private Stream _logFileStream; + private FileStream _logFileStream; private string _targetPath; private Process _process; private bool _disposed = false; @@ -308,7 +308,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - private async Task StartStreamingLog(Stream source, Stream target) + private async Task StartStreamingLog(Stream source, FileStream target) { try { diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs index dd427c7368..dcd1e8613c 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -1101,7 +1101,7 @@ namespace Emby.Server.Implementations.LiveTv progress.Report(100); } - private async Task, List>> RefreshChannelsInternal(ILiveTvService service, IProgress progress, CancellationToken cancellationToken) + private async Task, List>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress progress, CancellationToken cancellationToken) { progress.Report(10); diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs index 767b941366..c0ba8710ff 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs @@ -134,7 +134,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts } } - private void TrySeek(Stream stream, long offset) + private void TrySeek(FileStream stream, long offset) { if (!stream.CanSeek) { -- cgit v1.2.3 From f1a5bc955b6a182aaa19a80831560d43c50ede2a Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 5 Dec 2023 12:01:59 -0500 Subject: Use string.Contains in LiveTv --- Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs | 4 ++-- .../LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 6b0520ad0f..5be3a7488a 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -287,7 +287,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings IsMovie = IsMovie(details), Etag = programInfo.Md5, IsLive = string.Equals(programInfo.LiveTapeDelay, "live", StringComparison.OrdinalIgnoreCase), - IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).IndexOf("premiere", StringComparison.OrdinalIgnoreCase) != -1 + IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).Contains("premiere", StringComparison.OrdinalIgnoreCase) }; var showId = programId; @@ -414,7 +414,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings return null; } - if (uri.IndexOf("http", StringComparison.OrdinalIgnoreCase) != -1) + if (uri.Contains("http", StringComparison.OrdinalIgnoreCase)) { return uri; } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs index 42068cd340..39b3571422 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs @@ -30,7 +30,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { var model = ModelNumber ?? string.Empty; - if (model.IndexOf("hdtc", StringComparison.OrdinalIgnoreCase) != -1) + if (model.Contains("hdtc", StringComparison.OrdinalIgnoreCase)) { return true; } -- cgit v1.2.3 From 01480c7f209654a992be6c3d3ad360e9bd343090 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 5 Dec 2023 12:03:53 -0500 Subject: Fix disposable warnings in EmbyTV --- Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index 74b62ca3f2..cba24ca459 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -37,12 +37,11 @@ using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV { - public class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable + public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable { public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; @@ -74,7 +73,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); - private bool _disposed = false; + private bool _disposed; public EmbyTV( IServerApplicationHost appHost, @@ -2524,22 +2523,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV /// public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) { if (_disposed) { return; } - if (disposing) - { - _recordingDeleteSemaphore.Dispose(); - } + _recordingDeleteSemaphore.Dispose(); foreach (var pair in _activeRecordings.ToList()) { -- cgit v1.2.3 From ce6c0ad02bc77b5a43ba2f28d4a93813738d0c53 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 5 Dec 2023 12:06:30 -0500 Subject: Use ConfigureAwait in DirectRecorder --- .../LiveTv/EmbyTV/DirectRecorder.cs | 40 ++++++++++++++-------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs index 49833de737..ec4dbde30d 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs @@ -46,7 +46,15 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - await using (var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous)) + var output = new FileStream( + targetFile, + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous); + + await using (output.ConfigureAwait(false)) { onStarted(); @@ -80,24 +88,26 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - await using var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.CopyToBufferSize, FileOptions.Asynchronous); - - onStarted(); + var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.CopyToBufferSize, FileOptions.Asynchronous); + await using (output.ConfigureAwait(false)) + { + onStarted(); - _logger.LogInformation("Copying recording stream to file {0}", targetFile); + _logger.LogInformation("Copying recording stream to file {0}", targetFile); - // The media source if infinite so we need to handle stopping ourselves - using var durationToken = new CancellationTokenSource(duration); - using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - cancellationToken = linkedCancellationToken.Token; + // The media source if infinite so we need to handle stopping ourselves + using var durationToken = new CancellationTokenSource(duration); + using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + cancellationToken = linkedCancellationToken.Token; - await _streamHelper.CopyUntilCancelled( - await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), - output, - IODefaults.CopyToBufferSize, - cancellationToken).ConfigureAwait(false); + await _streamHelper.CopyUntilCancelled( + await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), + output, + IODefaults.CopyToBufferSize, + cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Recording completed to file {0}", targetFile); + _logger.LogInformation("Recording completed to file {0}", targetFile); + } } } } -- cgit v1.2.3 From 7bcfc5e925295d386a8bc70b47200254ae731813 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 5 Dec 2023 12:22:23 -0500 Subject: Use ConfigureAwait in XmlTvListingsProvider --- .../LiveTv/Listings/XmlTvListingsProvider.cs | 51 ++++++++++++++-------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs index 066afb956b..e60e9dcc1c 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs @@ -84,38 +84,53 @@ namespace Emby.Server.Implementations.LiveTv.Listings _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + } } else { - await using var stream = AsyncFile.OpenRead(info.Path); - return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + var stream = AsyncFile.OpenRead(info.Path); + await using (stream.ConfigureAwait(false)) + { + return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + } } } private async Task UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken) { - await using var fileStream = new FileStream(file, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - - if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase)) + var fileStream = new FileStream( + file, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous); + + await using (fileStream.ConfigureAwait(false)) { - try + if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase)) { - using var reader = new GZipStream(stream, CompressionMode.Decompress); - await reader.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + try + { + using var reader = new GZipStream(stream, CompressionMode.Decompress); + await reader.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error extracting from gz file {File}", originalUrl); + } } - catch (Exception ex) + else { - _logger.LogError(ex, "Error extracting from gz file {File}", originalUrl); + await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } - } - else - { - await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); - } - return file; + return file; + } } public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) -- cgit v1.2.3 From 42c95da6b08a5295e1d2f98922158d0702fd8d7e Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 5 Dec 2023 12:24:31 -0500 Subject: Use ConfigureAwait in BaseTunerHost --- .../LiveTv/TunerHosts/BaseTunerHost.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs index ff25ee5854..a4e45f4796 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs @@ -96,8 +96,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts try { Directory.CreateDirectory(Path.GetDirectoryName(channelCacheFile)); - await using var writeStream = AsyncFile.OpenWrite(channelCacheFile); - await JsonSerializer.SerializeAsync(writeStream, channels, cancellationToken: cancellationToken).ConfigureAwait(false); + var writeStream = AsyncFile.OpenWrite(channelCacheFile); + await using (writeStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(writeStream, channels, cancellationToken: cancellationToken).ConfigureAwait(false); + } } catch (IOException) { @@ -112,10 +115,14 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { try { - await using var readStream = AsyncFile.OpenRead(channelCacheFile); - var channels = await JsonSerializer.DeserializeAsync>(readStream, cancellationToken: cancellationToken) - .ConfigureAwait(false); - list.AddRange(channels); + var readStream = AsyncFile.OpenRead(channelCacheFile); + await using (readStream.ConfigureAwait(false)) + { + var channels = await JsonSerializer + .DeserializeAsync>(readStream, cancellationToken: cancellationToken) + .ConfigureAwait(false); + list.AddRange(channels); + } } catch (IOException) { -- cgit v1.2.3 From 4ec32b71f5fc765886e457ae2c626ea7227e6a37 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 5 Dec 2023 12:26:29 -0500 Subject: Use ConfigureAwait in M3uParser --- Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs index 341782d9d3..0b5575b994 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs @@ -66,7 +66,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts .ConfigureAwait(false); response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStreamAsync(cancellationToken); + return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); } private async Task> GetChannelsAsync(TextReader reader, string channelIdPrefix, string tunerHostId) -- cgit v1.2.3 From 3ecd68d832b1849b3bebd052933ce8ad7612bad7 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 5 Dec 2023 12:28:17 -0500 Subject: Use ConfigureAwait in SharedHttpStream --- .../LiveTv/TunerHosts/SharedHttpStream.cs | 29 ++++++++++++++++------ 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs index 51f46f4dac..efb84a5152 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs @@ -83,14 +83,27 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts Logger.LogInformation("Beginning {StreamType} stream to {FilePath}", GetType().Name, TempFilePath); using (response) { - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using var fileStream = new FileStream(TempFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - await StreamHelper.CopyToAsync( - stream, - fileStream, - IODefaults.CopyToBufferSize, - () => Resolve(openTaskCompletionSource), - cancellationToken).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + var fileStream = new FileStream( + TempFilePath, + FileMode.Create, + FileAccess.Write, + FileShare.Read, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous); + + await using (fileStream.ConfigureAwait(false)) + { + await StreamHelper.CopyToAsync( + stream, + fileStream, + IODefaults.CopyToBufferSize, + () => Resolve(openTaskCompletionSource), + cancellationToken).ConfigureAwait(false); + } + } } } catch (OperationCanceledException ex) -- cgit v1.2.3 From 5c593b120a5f3b90daa6305a96086eecfe546039 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 5 Dec 2023 12:36:24 -0500 Subject: Don't use List directly in ITunerHost method signatures --- Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs | 6 +++--- .../LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs | 2 +- Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs | 2 +- MediaBrowser.Controller/LiveTv/ITunerHost.cs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs index a4e45f4796..da597056a4 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs @@ -67,7 +67,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return list; } - protected virtual List GetTunerHosts() + protected virtual IList GetTunerHosts() { return GetConfiguration().TunerHosts .Where(i => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase)) @@ -166,9 +166,9 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return new List(); } - protected abstract Task GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, List currentLiveStreams, CancellationToken cancellationToken); + protected abstract Task GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, IList currentLiveStreams, CancellationToken cancellationToken); - public async Task GetChannelStream(string channelId, string streamId, List currentLiveStreams, CancellationToken cancellationToken) + public async Task GetChannelStream(string channelId, string streamId, IList currentLiveStreams, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(channelId); diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index 8cd0c4ffb7..79e15a82e2 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -527,7 +527,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return list; } - protected override async Task GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, List currentLiveStreams, CancellationToken cancellationToken) + protected override async Task GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, IList currentLiveStreams, CancellationToken cancellationToken) { var tunerCount = tunerHost.TunerCount; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs index db5e81df5f..11bf03b182 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -96,7 +96,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return Task.FromResult(list); } - protected override async Task GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, List currentLiveStreams, CancellationToken cancellationToken) + protected override async Task GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, IList currentLiveStreams, CancellationToken cancellationToken) { var tunerCount = tunerHost.TunerCount; diff --git a/MediaBrowser.Controller/LiveTv/ITunerHost.cs b/MediaBrowser.Controller/LiveTv/ITunerHost.cs index 24820abb90..b983091588 100644 --- a/MediaBrowser.Controller/LiveTv/ITunerHost.cs +++ b/MediaBrowser.Controller/LiveTv/ITunerHost.cs @@ -50,7 +50,7 @@ namespace MediaBrowser.Controller.LiveTv /// The current live streams. /// The cancellation token to cancel operation. /// Live stream wrapped in a task. - Task GetChannelStream(string channelId, string streamId, List currentLiveStreams, CancellationToken cancellationToken); + Task GetChannelStream(string channelId, string streamId, IList currentLiveStreams, CancellationToken cancellationToken); /// /// Gets the channel stream media sources. -- cgit v1.2.3 From 669baf98a577094c5b038c7782045b873d468c0b Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 5 Dec 2023 12:41:27 -0500 Subject: Make IRecorder an IDisposable --- Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs | 7 ++++++- Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs | 2 +- Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs | 4 ++-- Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs index ec4dbde30d..ddf7b882a7 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs @@ -14,7 +14,7 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV { - public class DirectRecorder : IRecorder + public sealed class DirectRecorder : IRecorder { private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; @@ -109,5 +109,10 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _logger.LogInformation("Recording completed to file {0}", targetFile); } } + + /// + public void Dispose() + { + } } } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index cba24ca459..abe3ff349f 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -1269,7 +1269,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV directStreamProvider = liveStreamResponse.Item2; } - var recorder = GetRecorder(mediaStreamInfo); + using var recorder = GetRecorder(mediaStreamInfo); recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath); recordPath = EnsureFileUnique(recordPath, timer.Id); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs index b548a92b19..9a9fd02739 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs @@ -25,7 +25,7 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV { - public class EncodedRecorder : IRecorder, IDisposable + public class EncodedRecorder : IRecorder { private readonly ILogger _logger; private readonly IMediaEncoder _mediaEncoder; @@ -37,7 +37,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private FileStream _logFileStream; private string _targetPath; private Process _process; - private bool _disposed = false; + private bool _disposed; public EncodedRecorder( ILogger logger, diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs index 7705132da2..de14d6d086 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs @@ -8,7 +8,7 @@ using MediaBrowser.Model.Dto; namespace Emby.Server.Implementations.LiveTv.EmbyTV { - public interface IRecorder + public interface IRecorder : IDisposable { /// /// Records the specified media source. -- cgit v1.2.3 From 192559db32ef60b2a56b7acf689b6edc3cdc3487 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 5 Dec 2023 14:26:35 -0500 Subject: Make ILiveStream an IDisposable --- .../Library/ExclusiveLiveStream.cs | 7 ++++++- .../LiveTv/TunerHosts/LiveStream.cs | 15 +++++++++++++++ MediaBrowser.Controller/Library/ILiveStream.cs | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs index 868071a992..b1649afad5 100644 --- a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs +++ b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs @@ -12,7 +12,7 @@ using MediaBrowser.Model.Dto; namespace Emby.Server.Implementations.Library { - public class ExclusiveLiveStream : ILiveStream + public sealed class ExclusiveLiveStream : ILiveStream { private readonly Func _closeFn; @@ -51,5 +51,10 @@ namespace Emby.Server.Implementations.Library { return Task.CompletedTask; } + + /// + public void Dispose() + { + } } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs index c0ba8710ff..c18594a29b 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs @@ -112,6 +112,21 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return stream; } + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool dispose) + { + if (dispose) + { + LiveStreamCancellationTokenSource?.Dispose(); + } + } + protected async Task DeleteTempFiles(string path, int retryCount = 0) { if (retryCount == 0) diff --git a/MediaBrowser.Controller/Library/ILiveStream.cs b/MediaBrowser.Controller/Library/ILiveStream.cs index 4c44a17fdd..bf64aca0f0 100644 --- a/MediaBrowser.Controller/Library/ILiveStream.cs +++ b/MediaBrowser.Controller/Library/ILiveStream.cs @@ -2,6 +2,7 @@ #pragma warning disable CA1711, CS1591 +using System; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -9,7 +10,7 @@ using MediaBrowser.Model.Dto; namespace MediaBrowser.Controller.Library { - public interface ILiveStream + public interface ILiveStream : IDisposable { int ConsumerCount { get; set; } -- cgit v1.2.3 From 3e5e600dc76efcaf776f15776112f6cf57356e8a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 7 Dec 2023 13:27:03 +0000 Subject: chore(deps): update github/codeql-action action to v2.22.9 --- .github/workflows/ci-codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index b525966453..b35a79efc6 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@407ffafae6a767df3e0230c3df91b6443ae8df75 # v2.22.8 + uses: github/codeql-action/init@c0d1daa7f7e14667747d73a7dbbe8c074bc8bfe2 # v2.22.9 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@407ffafae6a767df3e0230c3df91b6443ae8df75 # v2.22.8 + uses: github/codeql-action/autobuild@c0d1daa7f7e14667747d73a7dbbe8c074bc8bfe2 # v2.22.9 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@407ffafae6a767df3e0230c3df91b6443ae8df75 # v2.22.8 + uses: github/codeql-action/analyze@c0d1daa7f7e14667747d73a7dbbe8c074bc8bfe2 # v2.22.9 -- cgit v1.2.3 From 8abc653c296b4f754b78a57a027bb2becf82b580 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 7 Dec 2023 13:27:09 +0000 Subject: chore(deps): update actions/stale action to v9 --- .github/workflows/issue-stale.yml | 2 +- .github/workflows/pull-request-stale.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-stale.yml b/.github/workflows/issue-stale.yml index 926a7fbfb0..5a1ca9f7a2 100644 --- a/.github/workflows/issue-stale.yml +++ b/.github/workflows/issue-stale.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 + - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/.github/workflows/pull-request-stale.yaml b/.github/workflows/pull-request-stale.yaml index de093a9887..d01b3f4a1f 100644 --- a/.github/workflows/pull-request-stale.yaml +++ b/.github/workflows/pull-request-stale.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 + - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true -- cgit v1.2.3 From 033cfa59c499e5adbb949b708f06e56bf8932401 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Fri, 8 Dec 2023 15:45:36 -0700 Subject: Convert CollectionType to use lowercase enum names --- .../Images/CollectionFolderImageProvider.cs | 18 +++--- .../Images/DynamicImageProvider.cs | 8 +-- .../Library/LibraryManager.cs | 4 +- .../Library/Resolvers/Audio/AudioResolver.cs | 6 +- .../Library/Resolvers/Audio/MusicAlbumResolver.cs | 2 +- .../Library/Resolvers/Audio/MusicArtistResolver.cs | 2 +- .../Library/Resolvers/Books/BookResolver.cs | 2 +- .../Library/Resolvers/Movies/MovieResolver.cs | 34 +++++------ .../Library/Resolvers/PhotoAlbumResolver.cs | 4 +- .../Library/Resolvers/PhotoResolver.cs | 4 +- .../Library/Resolvers/PlaylistResolver.cs | 2 +- .../Library/Resolvers/TV/EpisodeResolver.cs | 2 +- .../Library/Resolvers/TV/SeriesResolver.cs | 4 +- .../Library/UserViewManager.cs | 22 +++---- .../LiveTv/LiveTvManager.cs | 2 +- .../Playlists/PlaylistsFolder.cs | 2 +- Jellyfin.Api/Controllers/GenresController.cs | 4 +- Jellyfin.Api/Controllers/ItemUpdateController.cs | 2 +- Jellyfin.Api/Controllers/ItemsController.cs | 2 +- Jellyfin.Api/Controllers/LibraryController.cs | 18 +++--- Jellyfin.Data/Enums/CollectionType.cs | 57 +++++++++--------- MediaBrowser.Controller/Entities/BaseItem.cs | 2 +- MediaBrowser.Controller/Entities/UserView.cs | 18 +++--- .../Entities/UserViewBuilder.cs | 68 +++++++++++----------- .../Library/AudioResolverTests.cs | 2 +- .../Library/EpisodeResolverTest.cs | 4 +- 26 files changed, 148 insertions(+), 147 deletions(-) diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index 6e8f77977e..34c722e41d 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -32,26 +32,26 @@ namespace Emby.Server.Implementations.Images switch (viewType) { - case CollectionType.Movies: + case CollectionType.movies: includeItemTypes = new[] { BaseItemKind.Movie }; break; - case CollectionType.TvShows: + case CollectionType.tvshows: includeItemTypes = new[] { BaseItemKind.Series }; break; - case CollectionType.Music: + case CollectionType.music: includeItemTypes = new[] { BaseItemKind.MusicAlbum }; break; - case CollectionType.MusicVideos: + case CollectionType.musicvideos: includeItemTypes = new[] { BaseItemKind.MusicVideo }; break; - case CollectionType.Books: + case CollectionType.books: includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook }; break; - case CollectionType.BoxSets: + case CollectionType.boxsets: includeItemTypes = new[] { BaseItemKind.BoxSet }; break; - case CollectionType.HomeVideos: - case CollectionType.Photos: + case CollectionType.homevideos: + case CollectionType.photos: includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo }; break; default: @@ -59,7 +59,7 @@ namespace Emby.Server.Implementations.Images break; } - var recursive = viewType != CollectionType.Playlists; + var recursive = viewType != CollectionType.playlists; return view.GetItemList(new InternalItemsQuery { diff --git a/Emby.Server.Implementations/Images/DynamicImageProvider.cs b/Emby.Server.Implementations/Images/DynamicImageProvider.cs index 5de53df739..6b2ae23b3c 100644 --- a/Emby.Server.Implementations/Images/DynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/DynamicImageProvider.cs @@ -36,7 +36,7 @@ namespace Emby.Server.Implementations.Images var view = (UserView)item; var isUsingCollectionStrip = IsUsingCollectionStrip(view); - var recursive = isUsingCollectionStrip && view?.ViewType is not null && view.ViewType != CollectionType.BoxSets && view.ViewType != CollectionType.Playlists; + var recursive = isUsingCollectionStrip && view?.ViewType is not null && view.ViewType != CollectionType.boxsets && view.ViewType != CollectionType.playlists; var result = view.GetItemList(new InternalItemsQuery { @@ -114,9 +114,9 @@ namespace Emby.Server.Implementations.Images { CollectionType[] collectionStripViewTypes = { - CollectionType.Movies, - CollectionType.TvShows, - CollectionType.Playlists + CollectionType.movies, + CollectionType.tvshows, + CollectionType.playlists }; return view?.ViewType is not null && collectionStripViewTypes.Contains(view.ViewType.Value); diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index f40177fa77..a79ffd9cb1 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1514,7 +1514,7 @@ namespace Emby.Server.Implementations.Library { if (item is UserView view) { - if (view.ViewType == CollectionType.LiveTv) + if (view.ViewType == CollectionType.livetv) { return new[] { view.Id }; } @@ -1543,7 +1543,7 @@ namespace Emby.Server.Implementations.Library } // Handle grouping - if (user is not null && view.ViewType != CollectionType.Unknown && UserView.IsEligibleForGrouping(view.ViewType) + if (user is not null && view.ViewType != CollectionType.unknown && UserView.IsEligibleForGrouping(view.ViewType) && user.GetPreference(PreferenceKind.GroupedFolders).Length > 0) { return GetUserRootFolder() diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs index ac423ed091..dbf05c1dbb 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs @@ -61,7 +61,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio List files, CollectionType? collectionType) { - if (collectionType == CollectionType.Books) + if (collectionType == CollectionType.books) { return ResolveMultipleAudio(parent, files, true); } @@ -80,7 +80,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio var collectionType = args.GetCollectionType(); - var isBooksCollectionType = collectionType == CollectionType.Books; + var isBooksCollectionType = collectionType == CollectionType.books; if (args.IsDirectory) { @@ -112,7 +112,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio MediaBrowser.Controller.Entities.Audio.Audio item = null; - var isMusicCollectionType = collectionType == CollectionType.Music; + var isMusicCollectionType = collectionType == CollectionType.music; // Use regular audio type for mixed libraries, owned items and music if (isMixedCollectionType || diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs index 06e292f4cf..0bfb7fbe6a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs @@ -55,7 +55,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio protected override MusicAlbum Resolve(ItemResolveArgs args) { var collectionType = args.GetCollectionType(); - var isMusicMediaFolder = collectionType == CollectionType.Music; + var isMusicMediaFolder = collectionType == CollectionType.music; // If there's a collection type and it's not music, don't allow it. if (!isMusicMediaFolder) diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs index 7d6f97b121..1bdae7f62b 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs @@ -65,7 +65,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio var collectionType = args.GetCollectionType(); - var isMusicMediaFolder = collectionType == CollectionType.Music; + var isMusicMediaFolder = collectionType == CollectionType.music; // If there's a collection type and it's not music, it can't be a music artist if (!isMusicMediaFolder) diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index b76bfe4274..464a548ab9 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -23,7 +23,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books var collectionType = args.GetCollectionType(); // Only process items that are in a collection folder containing books - if (collectionType != CollectionType.Books) + if (collectionType != CollectionType.books) { return null; } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 50fd8b8779..1a210e3cc8 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -31,11 +31,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies private static readonly CollectionType[] _validCollectionTypes = new[] { - CollectionType.Movies, - CollectionType.HomeVideos, - CollectionType.MusicVideos, - CollectionType.TvShows, - CollectionType.Photos + CollectionType.movies, + CollectionType.homevideos, + CollectionType.musicvideos, + CollectionType.tvshows, + CollectionType.photos }; /// @@ -100,12 +100,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies Video movie = null; var files = args.GetActualFileSystemChildren().ToList(); - if (collectionType == CollectionType.MusicVideos) + if (collectionType == CollectionType.musicvideos) { movie = FindMovie(args, args.Path, args.Parent, files, DirectoryService, collectionType, false); } - if (collectionType == CollectionType.HomeVideos) + if (collectionType == CollectionType.homevideos) { movie = FindMovie public class PluginConfiguration : BasePluginConfiguration { + /// + /// Gets or sets a value to use as the API key for accessing TMDb. This is intentionally excluded from the + /// settings page as the API key should not need to be changed by most users. + /// + public string TmdbApiKey { get; set; } = TmdbUtils.ApiKey; + /// /// Gets or sets a value indicating whether include adult content when searching with TMDb. /// diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index cd21516f95..f3c24e7b42 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -64,9 +64,18 @@ var clientConfig, pluginConfig; var configureImageScaling = function() { - if (clientConfig === null || pluginConfig === null) { + if (clientConfig === undefined || pluginConfig === undefined) { return; } + if (Object.keys(clientConfig).length === 0) { + clientConfig = { + PosterSizes: [pluginConfig.PosterSize], + BackdropSizes: [pluginConfig.BackdropSize], + LogoSizes: [pluginConfig.LogoSize], + ProfileSizes: [pluginConfig.ProfileSize], + StillSizes: [pluginConfig.StillSize] + }; + } var sizeOptionsGenerator = function (size) { return ''; @@ -104,6 +113,15 @@ ApiClient.fetch(request).then(function (config) { clientConfig = config; configureImageScaling(); + }, function (error) { + error.text().then(function (contents) { + Dashboard.alert({ + title: error.statusText, + message: contents + }); + clientConfig = {}; + configureImageScaling(); + }); }); ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 72e59c9ac4..1402104334 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -36,7 +36,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public TmdbClientManager(IMemoryCache memoryCache) { _memoryCache = memoryCache; - _tmDbClient = new TMDbClient(TmdbUtils.ApiKey); + _tmDbClient = new TMDbClient(Plugin.Instance.Configuration.TmdbApiKey); // Not really interested in NotFoundException _tmDbClient.ThrowApiExceptions = false; } -- cgit v1.2.3 From f7479bc7301391d1aed9b2cb1104e501087bb6d6 Mon Sep 17 00:00:00 2001 From: Joe Rogers <1337joe@gmail.com> Date: Sat, 16 Dec 2023 23:28:50 -0500 Subject: Fix missing posters in collection identify --- MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs | 8 +++++--- MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index c2018d820e..c76c65591f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -75,12 +75,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets var collections = new RemoteSearchResult[collectionSearchResults.Count]; for (var i = 0; i < collectionSearchResults.Count; i++) { + var result = collectionSearchResults[i]; var collection = new RemoteSearchResult { - Name = collectionSearchResults[i].Name, - SearchProviderName = Name + Name = result.Name, + SearchProviderName = Name, + ImageUrl = _tmdbClientManager.GetPosterUrl(result.PosterPath) }; - collection.SetProviderId(MetadataProvider.Tmdb, collectionSearchResults[i].Id.ToString(CultureInfo.InvariantCulture)); + collection.SetProviderId(MetadataProvider.Tmdb, result.Id.ToString(CultureInfo.InvariantCulture)); collections[i] = collection; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 2f62e117eb..dac7a74ed8 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -299,7 +299,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (!string.IsNullOrWhiteSpace(person.ProfilePath)) { - personInfo.ImageUrl = _tmdbClientManager.GetPosterUrl(person.ProfilePath); + personInfo.ImageUrl = _tmdbClientManager.GetProfileUrl(person.ProfilePath); } if (person.Id > 0) -- cgit v1.2.3 From 49cc4d1dabf23602d775f194b75402d65e0722b8 Mon Sep 17 00:00:00 2001 From: Joe Rogers <1337joe@gmail.com> Date: Sun, 17 Dec 2023 00:12:11 -0500 Subject: Add full version tags for consistency with other files --- .github/workflows/ci-tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 323d920453..0dacbc5c61 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -19,9 +19,9 @@ jobs: runs-on: "${{ matrix.os }}" steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4 + - uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 with: dotnet-version: ${{ env.SDK_VERSION }} @@ -34,7 +34,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@4d510cbed8a05af5aefea46c7fd6e05b95844c89 # 5 + uses: danielpalme/ReportGenerator-GitHub-Action@4d510cbed8a05af5aefea46c7fd6e05b95844c89 # 5.2.0 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" -- cgit v1.2.3 From e91de654d78c847d482fb9ca315eca31d044d770 Mon Sep 17 00:00:00 2001 From: Joe Rogers <1337joe@gmail.com> Date: Sun, 17 Dec 2023 22:14:11 -0500 Subject: Stop saving Jellyfin API key in settings xml --- .../Plugins/Tmdb/Configuration/PluginConfiguration.cs | 2 +- MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index e9cd81a14e..99b759ae29 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -11,7 +11,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Gets or sets a value to use as the API key for accessing TMDb. This is intentionally excluded from the /// settings page as the API key should not need to be changed by most users. /// - public string TmdbApiKey { get; set; } = TmdbUtils.ApiKey; + public string TmdbApiKey { get; set; } = string.Empty; /// /// Gets or sets a value indicating whether include adult content when searching with TMDb. diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 1402104334..82f2c54f16 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -36,7 +36,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public TmdbClientManager(IMemoryCache memoryCache) { _memoryCache = memoryCache; - _tmDbClient = new TMDbClient(Plugin.Instance.Configuration.TmdbApiKey); + + var apiKey = Plugin.Instance.Configuration.TmdbApiKey; + apiKey = string.IsNullOrEmpty(apiKey) ? TmdbUtils.ApiKey : apiKey; + _tmDbClient = new TMDbClient(apiKey); + // Not really interested in NotFoundException _tmDbClient.ThrowApiExceptions = false; } -- cgit v1.2.3 From 660165cd2ff6288061a90c968381eb014ec3b7fd Mon Sep 17 00:00:00 2001 From: Lars Eichler Date: Mon, 18 Dec 2023 10:46:09 -0500 Subject: Added translation using Weblate (Abkhazian) --- Emby.Server.Implementations/Localization/Core/ab.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 Emby.Server.Implementations/Localization/Core/ab.json diff --git a/Emby.Server.Implementations/Localization/Core/ab.json b/Emby.Server.Implementations/Localization/Core/ab.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ab.json @@ -0,0 +1 @@ +{} -- cgit v1.2.3 From 12df192a31cc0e737d58d4cf517784249bfd9d0b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 20:14:50 +0000 Subject: chore(deps): update xunit-dotnet monorepo --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a1efdb37b6..07fd257436 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,8 +83,8 @@ - + - + -- cgit v1.2.3 From 7bf831da62f00b7df2e847d6298012b17997285f Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Mon, 18 Dec 2023 22:02:31 +0100 Subject: Fix tests --- .../Controllers/ImageControllerTests.cs | 2 +- .../CopyToExtensionsTests.cs | 40 ++++++++-------------- .../Entities/MediaStreamTests.cs | 2 +- .../Video/CleanDateTimeTests.cs | 3 +- .../Configuration/NetworkConfigurationTests.cs | 1 - .../MediaInfo/EmbeddedImageProviderTests.cs | 4 +-- .../Tmdb/TmdbUtilsTests.cs | 5 +-- .../Library/PathExtensionsTests.cs | 2 +- .../QuickConnect/QuickConnectManagerTests.cs | 4 +-- .../SessionManager/SessionManagerTests.cs | 2 +- 10 files changed, 25 insertions(+), 40 deletions(-) diff --git a/tests/Jellyfin.Api.Tests/Controllers/ImageControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/ImageControllerTests.cs index 0254a1ec6e..5034ad3c71 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/ImageControllerTests.cs +++ b/tests/Jellyfin.Api.Tests/Controllers/ImageControllerTests.cs @@ -27,7 +27,7 @@ public static class ImageControllerTests [InlineData(null)] [InlineData("")] [InlineData("text/html")] - public static void TryGetImageExtensionFromContentType_InValid_False(string contentType) + public static void TryGetImageExtensionFromContentType_InValid_False(string? contentType) { Assert.False(ImageController.TryGetImageExtensionFromContentType(contentType, out var ex)); Assert.Null(ex); diff --git a/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs index d46beedd99..95f9a5fcfb 100644 --- a/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs +++ b/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs @@ -8,20 +8,18 @@ namespace Jellyfin.Extensions.Tests { public static TheoryData, IList, int, IList> CopyTo_Valid_Correct_TestData() { - var data = new TheoryData, IList, int, IList>(); - - data.Add( - new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, 0, new[] { 0, 1, 2, 3, 4, 5 }); - - data.Add( - new[] { 0, 1, 2 }, new[] { 5, 4, 3, 2, 1, 0 }, 2, new[] { 5, 4, 0, 1, 2, 0 } ); + var data = new TheoryData, IList, int, IList> + { + { new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, 0, new[] { 0, 1, 2, 3, 4, 5 } }, + { new[] { 0, 1, 2 }, new[] { 5, 4, 3, 2, 1, 0 }, 2, new[] { 5, 4, 0, 1, 2, 0 } } + }; return data; } [Theory] [MemberData(nameof(CopyTo_Valid_Correct_TestData))] - public static void CopyTo_Valid_Correct(IReadOnlyList source, IList destination, int index, IList expected) + public static void CopyTo_Valid_Correct(IReadOnlyList source, IList destination, int index, IList expected) { source.CopyTo(destination, index); Assert.Equal(expected, destination); @@ -29,29 +27,21 @@ namespace Jellyfin.Extensions.Tests public static TheoryData, IList, int> CopyTo_Invalid_ThrowsArgumentOutOfRangeException_TestData() { - var data = new TheoryData, IList, int>(); - - data.Add( - new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, -1 ); - - data.Add( - new[] { 0, 1, 2 }, new[] { 5, 4, 3, 2, 1, 0 }, 6 ); - - data.Add( - new[] { 0, 1, 2 }, Array.Empty(), 0 ); - - data.Add( - new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0 }, 0 ); - - data.Add( - new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, 1 ); + var data = new TheoryData, IList, int> + { + { new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, -1 }, + { new[] { 0, 1, 2 }, new[] { 5, 4, 3, 2, 1, 0 }, 6 }, + { new[] { 0, 1, 2 }, Array.Empty(), 0 }, + { new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0 }, 0 }, + { new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, 1 } + }; return data; } [Theory] [MemberData(nameof(CopyTo_Invalid_ThrowsArgumentOutOfRangeException_TestData))] - public static void CopyTo_Invalid_ThrowsArgumentOutOfRangeException(IReadOnlyList source, IList destination, int index) + public static void CopyTo_Invalid_ThrowsArgumentOutOfRangeException(IReadOnlyList source, IList destination, int index) { Assert.Throws(() => source.CopyTo(destination, index)); } diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs index d39a22e30b..f4c0d9fe8f 100644 --- a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs +++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs @@ -192,7 +192,7 @@ namespace Jellyfin.Model.Tests.Entities [InlineData(4090, 3070, false, "4K")] [InlineData(7680, 4320, false, "8K")] [InlineData(8190, 6140, false, "8K")] - public void GetResolutionText_Valid(int? width, int? height, bool interlaced, string expected) + public void GetResolutionText_Valid(int? width, int? height, bool interlaced, string? expected) { var mediaStream = new MediaStream() { diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs index 62d60e5a4b..5029a87933 100644 --- a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs @@ -52,9 +52,8 @@ namespace Jellyfin.Naming.Tests.Video [InlineData("My Movie 2013-12-09", "My Movie 2013-12-09", null)] [InlineData("My Movie 20131209", "My Movie 20131209", null)] [InlineData("My Movie 2013-12-09 2013", "My Movie 2013-12-09", 2013)] - [InlineData(null, null, null)] [InlineData("", "", null)] - public void CleanDateTimeTest(string input, string expectedName, int? expectedYear) + public void CleanDateTimeTest(string input, string? expectedName, int? expectedYear) { input = Path.GetFileName(input); diff --git a/tests/Jellyfin.Networking.Tests/Configuration/NetworkConfigurationTests.cs b/tests/Jellyfin.Networking.Tests/Configuration/NetworkConfigurationTests.cs index 30726f1d3c..f337fe20b8 100644 --- a/tests/Jellyfin.Networking.Tests/Configuration/NetworkConfigurationTests.cs +++ b/tests/Jellyfin.Networking.Tests/Configuration/NetworkConfigurationTests.cs @@ -6,7 +6,6 @@ namespace Jellyfin.Networking.Tests.Configuration; public static class NetworkConfigurationTests { [Theory] - [InlineData("", null)] [InlineData("", "")] [InlineData("/Test", "/Test")] [InlineData("/Test", "Test")] diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs index 2bc686a337..85963e5def 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs @@ -55,7 +55,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo [InlineData("clearlogo.png", null, 1, ImageType.Logo, ImageFormat.Png)] // extract extension from name [InlineData("backdrop", "image/bmp", 2, ImageType.Backdrop, ImageFormat.Bmp)] // extract extension from mimetype [InlineData("poster", null, 3, ImageType.Primary, ImageFormat.Jpg)] // default extension to jpg - public async void GetImage_Attachment_ReturnsCorrectSelection(string filename, string mimetype, int targetIndex, ImageType type, ImageFormat? expectedFormat) + public async void GetImage_Attachment_ReturnsCorrectSelection(string filename, string? mimetype, int targetIndex, ImageType type, ImageFormat? expectedFormat) { var attachments = new List(); string pathPrefix = "path"; @@ -103,7 +103,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo [InlineData(null, "mjpeg", 1, ImageType.Primary, ImageFormat.Jpg)] [InlineData(null, "png", 1, ImageType.Primary, ImageFormat.Png)] [InlineData(null, "webp", 1, ImageType.Primary, ImageFormat.Webp)] - public async void GetImage_Embedded_ReturnsCorrectSelection(string label, string? codec, int targetIndex, ImageType type, ImageFormat? expectedFormat) + public async void GetImage_Embedded_ReturnsCorrectSelection(string? label, string? codec, int targetIndex, ImageType type, ImageFormat? expectedFormat) { var streams = new List(); for (int i = 1; i <= targetIndex; i++) diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs index efd2d9553f..0bfa330cb6 100644 --- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs @@ -25,14 +25,11 @@ namespace Jellyfin.Providers.Tests.Tmdb } [Theory] - [InlineData(null, null, null)] - [InlineData(null, "en-US", null)] - [InlineData("en", null, "en")] [InlineData("en", "en-US", "en-US")] [InlineData("fr-CA", "fr-BE", "fr-CA")] [InlineData("fr-CA", "fr", "fr-CA")] [InlineData("de", "en-US", "de")] - public static void AdjustImageLanguage_Valid_Success(string imageLanguage, string requestLanguage, string expected) + public static void AdjustImageLanguage_Valid_Success(string imageLanguage, string requestLanguage, string? expected) { Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage)); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index 1c35eb3f50..d1be07aa22 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs @@ -83,7 +83,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library [InlineData(@"\home/jeff\myfile.mkv", '\\', @"\home\jeff\myfile.mkv")] [InlineData(@"\home/jeff\myfile.mkv", '/', "/home/jeff/myfile.mkv")] [InlineData("", '/', "")] - public void NormalizePath_SpecifyingSeparator_Normalizes(string path, char separator, string expectedPath) + public void NormalizePath_SpecifyingSeparator_Normalizes(string? path, char separator, string? expectedPath) { Assert.Equal(expectedPath, path.NormalizePath(separator)); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/QuickConnect/QuickConnectManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/QuickConnect/QuickConnectManagerTests.cs index c32d89ea5d..30f72f5957 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/QuickConnect/QuickConnectManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/QuickConnect/QuickConnectManagerTests.cs @@ -85,10 +85,10 @@ namespace Jellyfin.Server.Implementations.Tests.QuickConnect } [Fact] - public void AuthorizeRequest_QuickConnectUnavailable_ThrowsAuthenticationException() + public async Task AuthorizeRequest_QuickConnectUnavailable_ThrowsAuthenticationException() { _config.QuickConnectAvailable = false; - Assert.ThrowsAsync(() => _quickConnectManager.AuthorizeRequest(Guid.Empty, string.Empty)); + await Assert.ThrowsAsync(() => _quickConnectManager.AuthorizeRequest(Guid.Empty, string.Empty)); } [Fact] diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs index ebd3a3891c..e463d838e6 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs @@ -21,7 +21,7 @@ public class SessionManagerTests [Theory] [InlineData("", typeof(ArgumentException))] [InlineData(null, typeof(ArgumentNullException))] - public async Task GetAuthorizationToken_Should_ThrowException(string deviceId, Type exceptionType) + public async Task GetAuthorizationToken_Should_ThrowException(string? deviceId, Type exceptionType) { await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager( NullLogger.Instance, -- cgit v1.2.3 From 05bc894ae86bfd4341766bc14b25c282865c4d98 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 Dec 2023 00:26:16 +0000 Subject: chore(deps): update actions/download-artifact action to v4.1.0 --- .github/workflows/ci-openapi.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 5ff9820cbc..c9f17429a2 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -78,12 +78,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@7a1cd3216ca9260cd8022db641d960b1db4d1be4 # v4.0.0 + uses: actions/download-artifact@f44cd7b40bfd40b6aa1cc1b9b5b7bf03d3c67110 # v4.1.0 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@7a1cd3216ca9260cd8022db641d960b1db4d1be4 # v4.0.0 + uses: actions/download-artifact@f44cd7b40bfd40b6aa1cc1b9b5b7bf03d3c67110 # v4.1.0 with: name: openapi-base path: openapi-base -- cgit v1.2.3 From a42951c0a81edd0c70f1722c7c19415de2d6ccdd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 21 Dec 2023 00:29:35 +0000 Subject: chore(deps): update dependency stylecop.analyzers to v1.2.0-beta.556 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 07fd257436..0c234bcf8b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -70,7 +70,7 @@ - + -- cgit v1.2.3 From d92f2ac31cad5b667125a055708a080605ee53f6 Mon Sep 17 00:00:00 2001 From: Dmitry Lyzo Date: Tue, 19 Dec 2023 23:21:57 +0300 Subject: test: fix remux tests This partially reverts commit 2d8f7b46f11ee458fe0b31d8e1473aaaea9cf296 --- tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index 210ce4a47f..56c2c554da 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -27,7 +27,7 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] - [InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported)] // #6450 + [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-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // Firefox @@ -38,7 +38,7 @@ namespace Jellyfin.Model.Tests [InlineData("Firefox", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] - [InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported)] // #6450 + [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-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // Safari @@ -89,7 +89,7 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome-NoHLS", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome-NoHLS", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")] [InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "http")] - [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported)] // #6450 + [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-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // TranscodeMedia @@ -177,7 +177,7 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] - [InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported)] // #6450 + [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-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // Firefox @@ -187,7 +187,7 @@ namespace Jellyfin.Model.Tests [InlineData("Firefox", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] - [InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported)] // #6450 + [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-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // Safari @@ -419,14 +419,7 @@ namespace Jellyfin.Model.Tests if (targetAudioStream?.IsExternal == false) { // Check expected audio codecs (1) - if (streamInfo.TranscodeReasons.HasFlag(TranscodeReason.ContainerNotSupported)) - { - Assert.Contains(targetAudioStream.Codec, streamInfo.AudioCodecs); - } - else - { - Assert.DoesNotContain(targetAudioStream.Codec, streamInfo.AudioCodecs); - } + Assert.DoesNotContain(targetAudioStream.Codec, streamInfo.AudioCodecs); } } else if (transcodeMode.Equals("Remux", StringComparison.Ordinal)) -- cgit v1.2.3 From 8c29fa422a65b9c9abe239cf4536804e5c08aa2c Mon Sep 17 00:00:00 2001 From: Dmitry Lyzo Date: Wed, 20 Dec 2023 22:36:25 +0300 Subject: test: fix tizen profile --- tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs | 8 ++++---- .../Test Data/DeviceProfile-Tizen3-stereo.json | 13 +++++++++++++ .../Test Data/DeviceProfile-Tizen4-4K-5.1.json | 13 +++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index 56c2c554da..c567f280a0 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -291,11 +291,11 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidTVExoPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] [InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // Tizen 3 Stereo - [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] - [InlineData("Tizen3-stereo", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] + [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + [InlineData("Tizen3-stereo", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // Tizen 4 4K 5.1 - [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] - [InlineData("Tizen4-4K-5.1", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] + [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + [InlineData("Tizen4-4K-5.1", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] public async Task BuildVideoItemWithDirectPlayExplicitStreams(string deviceName, string mediaSource, PlayMethod? playMethod, TranscodeReason why = default, string transcodeMode = "DirectStream", string transcodeProtocol = "") { var options = await GetMediaOptions(deviceName, mediaSource); diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json index 53637b7931..2e3e6e6de4 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json @@ -413,6 +413,19 @@ } ], "CodecProfiles": [ + { + "Type": "VideoAudio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "IsSecondaryAudio", + "Value": "false", + "IsRequired": false, + "$type": "ProfileCondition" + } + ], + "$type": "CodecProfile" + }, { "Type": "Video", "Conditions": [ diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json index d3ef22c256..1562304718 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json @@ -413,6 +413,19 @@ } ], "CodecProfiles": [ + { + "Type": "VideoAudio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "IsSecondaryAudio", + "Value": "false", + "IsRequired": false, + "$type": "ProfileCondition" + } + ], + "$type": "CodecProfile" + }, { "Type": "Video", "Conditions": [ -- cgit v1.2.3 From ca280279da9a21775098ec73aa4c85933a443597 Mon Sep 17 00:00:00 2001 From: Dmitry Lyzo Date: Wed, 20 Dec 2023 22:38:01 +0300 Subject: test: add more audio tests --- .../Dlna/StreamBuilderTests.cs | 5 + ...aSourceInfo-mp4-h264-ac3-aac-aac-srt-2600k.json | 102 +++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-aac-srt-2600k.json diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index c567f280a0..2f84fa544e 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -274,13 +274,16 @@ namespace Jellyfin.Model.Tests [Theory] // Chrome [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Firefox [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Yatse [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Yatse", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -292,9 +295,11 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // Tizen 3 Stereo [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] [InlineData("Tizen3-stereo", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // Tizen 4 4K 5.1 [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] [InlineData("Tizen4-4K-5.1", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] public async Task BuildVideoItemWithDirectPlayExplicitStreams(string deviceName, string mediaSource, PlayMethod? playMethod, TranscodeReason why = default, string transcodeMode = "DirectStream", string transcodeProtocol = "") { diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-aac-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-aac-srt-2600k.json new file mode 100644 index 0000000000..9d819c4ada --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-aac-srt-2600k.json @@ -0,0 +1,102 @@ +{ + "Id": "a766d122b58e45d9492d17af77748bf5", + "Path": "/Media/MyVideo-720p.mp4", + "Container": "mov,mp4,m4a,3gp,3g2,mj2", + "Size": 835317696, + "Name": "MyVideo-720p", + "ETag": "579a34c6d5dfb21d81539a51220b6a23", + "RunTimeTicks": 25801230336, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "h264", + "CodecTag": "avc1", + "Language": "eng", + "TimeBase": "1/11988", + "VideoRange": "SDR", + "DisplayTitle": "720p H264 SDR", + "NalLengthSize": "0", + "BitRate": 2032876, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "Height": 720, + "Width": 1280, + "AverageFrameRate": 23.976, + "RealFrameRate": 23.976, + "Profile": "High", + "Type": 1, + "AspectRatio": "16:9", + "PixelFormat": "yuv420p", + "Level": 41 + }, + { + "Codec": "ac3", + "CodecTag": "ac-3", + "Language": "eng", + "TimeBase": "1/48000", + "DisplayTitle": "En - Dolby Digital - 5.1 - Default", + "ChannelLayout": "5.1", + "BitRate": 384000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "Index": 1, + "Score": 202 + }, + { + "Codec": "aac", + "CodecTag": "mp4a", + "Language": "eng", + "TimeBase": "1/48000", + "DisplayTitle": "En - AAC - Stereo - Default", + "ChannelLayout": "stereo", + "BitRate": 164741, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": true, + "Profile": "LC", + "Index": 2, + "Score": 203 + }, + { + "Codec": "aac", + "CodecTag": "mp4a", + "Language": "rus", + "TimeBase": "1/48000", + "DisplayTitle": "Ru - AAC - Stereo - Default", + "ChannelLayout": "stereo", + "BitRate": 164741, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": false, + "Profile": "LC", + "Index": 3, + "Score": 203 + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 4, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "/Media/MyVideo-WEBDL-2160p.default.eng.srt" + } + ], + "Bitrate": 2590008, + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": 4 +} -- cgit v1.2.3 From abd74fd5a481d67af1414960be7b7b19c9ee7e82 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 31 Oct 2023 11:12:09 -0400 Subject: Move TranscodingJobDto and TranscodingThrottler to Controller --- Jellyfin.Api/Controllers/DynamicHlsController.cs | 1 - Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs | 1 - Jellyfin.Api/Helpers/ProgressiveFileStream.cs | 2 +- Jellyfin.Api/Helpers/TranscodingJobHelper.cs | 1 - .../Models/PlaybackDtos/TranscodingJobDto.cs | 283 --------------------- .../Models/PlaybackDtos/TranscodingThrottler.cs | 219 ---------------- Jellyfin.Api/Models/StreamingDtos/StreamState.cs | 1 - .../MediaEncoding/TranscodingJobDto.cs | 282 ++++++++++++++++++++ .../MediaEncoding/TranscodingThrottler.cs | 218 ++++++++++++++++ 9 files changed, 501 insertions(+), 507 deletions(-) delete mode 100644 Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs delete mode 100644 Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs create mode 100644 MediaBrowser.Controller/MediaEncoding/TranscodingJobDto.cs create mode 100644 MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 9e9c610cc5..6b2c423632 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -10,7 +10,6 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Helpers; -using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Data.Enums; using Jellyfin.Extensions; diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 0f0a70c698..ce2e476b7d 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -4,7 +4,6 @@ using System.Net.Http; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Controller.MediaEncoding; using Microsoft.AspNetCore.Http; diff --git a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs index d7b1c9f8bb..379ec6e27b 100644 --- a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs +++ b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs @@ -3,7 +3,7 @@ using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.IO; namespace Jellyfin.Api.Helpers; diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 77d3edbd65..14436909c9 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -9,7 +9,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Data.Enums; using MediaBrowser.Common; diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs deleted file mode 100644 index 480ddab098..0000000000 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs +++ /dev/null @@ -1,283 +0,0 @@ -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.Dto; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Api.Models.PlaybackDtos; - -/// -/// Class TranscodingJob. -/// -public class TranscodingJobDto : IDisposable -{ - /// - /// The process lock. - /// - [SuppressMessage("Microsoft.Performance", "CA1051:NoVisibleInstanceFields", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "SA1401:PrivateField", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] - public readonly object ProcessLock = new object(); - - /// - /// Timer lock. - /// - private readonly object _timerLock = new object(); - - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - public TranscodingJobDto(ILogger logger) - { - Logger = logger; - } - - /// - /// Gets or sets the play session identifier. - /// - /// The play session identifier. - public string? PlaySessionId { get; set; } - - /// - /// Gets or sets the live stream identifier. - /// - /// The live stream identifier. - public string? LiveStreamId { get; set; } - - /// - /// Gets or sets a value indicating whether is live output. - /// - public bool IsLiveOutput { get; set; } - - /// - /// Gets or sets the path. - /// - /// The path. - public MediaSourceInfo? MediaSource { get; set; } - - /// - /// Gets or sets path. - /// - public string? Path { get; set; } - - /// - /// Gets or sets the type. - /// - /// The type. - public TranscodingJobType Type { get; set; } - - /// - /// Gets or sets the process. - /// - /// The process. - public Process? Process { get; set; } - - /// - /// Gets logger. - /// - public ILogger Logger { get; private set; } - - /// - /// Gets or sets the active request count. - /// - /// The active request count. - public int ActiveRequestCount { get; set; } - - /// - /// Gets or sets the kill timer. - /// - /// The kill timer. - private Timer? KillTimer { get; set; } - - /// - /// Gets or sets device id. - /// - public string? DeviceId { get; set; } - - /// - /// Gets or sets cancellation token source. - /// - public CancellationTokenSource? CancellationTokenSource { get; set; } - - /// - /// Gets or sets a value indicating whether has exited. - /// - public bool HasExited { get; set; } - - /// - /// Gets or sets exit code. - /// - public int ExitCode { get; set; } - - /// - /// Gets or sets a value indicating whether is user paused. - /// - public bool IsUserPaused { get; set; } - - /// - /// Gets or sets id. - /// - public string? Id { get; set; } - - /// - /// Gets or sets framerate. - /// - public float? Framerate { get; set; } - - /// - /// Gets or sets completion percentage. - /// - public double? CompletionPercentage { get; set; } - - /// - /// Gets or sets bytes downloaded. - /// - public long BytesDownloaded { get; set; } - - /// - /// Gets or sets bytes transcoded. - /// - public long? BytesTranscoded { get; set; } - - /// - /// Gets or sets bit rate. - /// - public int? BitRate { get; set; } - - /// - /// Gets or sets transcoding position ticks. - /// - public long? TranscodingPositionTicks { get; set; } - - /// - /// Gets or sets download position ticks. - /// - public long? DownloadPositionTicks { get; set; } - - /// - /// Gets or sets transcoding throttler. - /// - public TranscodingThrottler? TranscodingThrottler { get; set; } - - /// - /// Gets or sets last ping date. - /// - public DateTime LastPingDate { get; set; } - - /// - /// Gets or sets ping timeout. - /// - public int PingTimeout { get; set; } - - /// - /// Stop kill timer. - /// - public void StopKillTimer() - { - lock (_timerLock) - { - KillTimer?.Change(Timeout.Infinite, Timeout.Infinite); - } - } - - /// - /// Dispose kill timer. - /// - public void DisposeKillTimer() - { - lock (_timerLock) - { - if (KillTimer is not null) - { - KillTimer.Dispose(); - KillTimer = null; - } - } - } - - /// - /// Start kill timer. - /// - /// Callback action. - public void StartKillTimer(Action callback) - { - StartKillTimer(callback, PingTimeout); - } - - /// - /// Start kill timer. - /// - /// Callback action. - /// Callback interval. - public void StartKillTimer(Action callback, int intervalMs) - { - if (HasExited) - { - return; - } - - lock (_timerLock) - { - if (KillTimer is null) - { - Logger.LogDebug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer = new Timer(new TimerCallback(callback), this, intervalMs, Timeout.Infinite); - } - else - { - Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer.Change(intervalMs, Timeout.Infinite); - } - } - } - - /// - /// Change kill timer if started. - /// - public void ChangeKillTimerIfStarted() - { - if (HasExited) - { - return; - } - - lock (_timerLock) - { - if (KillTimer is not null) - { - var intervalMs = PingTimeout; - - Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer.Change(intervalMs, Timeout.Infinite); - } - } - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Dispose all resources. - /// - /// Whether to dispose all resources. - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - Process?.Dispose(); - Process = null; - KillTimer?.Dispose(); - KillTimer = null; - CancellationTokenSource?.Dispose(); - CancellationTokenSource = null; - TranscodingThrottler?.Dispose(); - TranscodingThrottler = null; - } - } -} diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs deleted file mode 100644 index b577c4ea6a..0000000000 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Api.Models.PlaybackDtos; - -/// -/// Transcoding throttler. -/// -public class TranscodingThrottler : IDisposable -{ - private readonly TranscodingJobDto _job; - private readonly ILogger _logger; - private readonly IConfigurationManager _config; - private readonly IFileSystem _fileSystem; - private readonly IMediaEncoder _mediaEncoder; - private Timer? _timer; - private bool _isPaused; - - /// - /// Initializes a new instance of the class. - /// - /// Transcoding job dto. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public TranscodingThrottler(TranscodingJobDto job, ILogger logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder) - { - _job = job; - _logger = logger; - _config = config; - _fileSystem = fileSystem; - _mediaEncoder = mediaEncoder; - } - - /// - /// Start timer. - /// - public void Start() - { - _timer = new Timer(TimerCallback, null, 5000, 5000); - } - - /// - /// Unpause transcoding. - /// - /// A . - public async Task UnpauseTranscoding() - { - if (_isPaused) - { - _logger.LogDebug("Sending resume command to ffmpeg"); - - try - { - var resumeKey = _mediaEncoder.IsPkeyPauseSupported ? "u" : Environment.NewLine; - await _job.Process!.StandardInput.WriteAsync(resumeKey).ConfigureAwait(false); - _isPaused = false; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error resuming transcoding"); - } - } - } - - /// - /// Stop throttler. - /// - /// A . - public async Task Stop() - { - DisposeTimer(); - await UnpauseTranscoding().ConfigureAwait(false); - } - - /// - /// Dispose throttler. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Dispose throttler. - /// - /// Disposing. - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - DisposeTimer(); - } - } - - private EncodingOptions GetOptions() - { - return _config.GetEncodingOptions(); - } - - private async void TimerCallback(object? state) - { - if (_job.HasExited) - { - DisposeTimer(); - return; - } - - var options = GetOptions(); - - if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds)) - { - await PauseTranscoding().ConfigureAwait(false); - } - else - { - await UnpauseTranscoding().ConfigureAwait(false); - } - } - - private async Task PauseTranscoding() - { - if (!_isPaused) - { - var pauseKey = _mediaEncoder.IsPkeyPauseSupported ? "p" : "c"; - - _logger.LogDebug("Sending pause command [{Key}] to ffmpeg", pauseKey); - - try - { - await _job.Process!.StandardInput.WriteAsync(pauseKey).ConfigureAwait(false); - _isPaused = true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error pausing transcoding"); - } - } - } - - private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds) - { - var bytesDownloaded = job.BytesDownloaded; - var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0; - var downloadPositionTicks = job.DownloadPositionTicks ?? 0; - - var path = job.Path ?? throw new ArgumentException("Path can't be null."); - - var gapLengthInTicks = TimeSpan.FromSeconds(thresholdSeconds).Ticks; - - if (downloadPositionTicks > 0 && transcodingPositionTicks > 0) - { - // HLS - time-based consideration - - var targetGap = gapLengthInTicks; - var gap = transcodingPositionTicks - downloadPositionTicks; - - if (gap < targetGap) - { - _logger.LogDebug("Not throttling transcoder gap {0} target gap {1}", gap, targetGap); - return false; - } - - _logger.LogDebug("Throttling transcoder gap {0} target gap {1}", gap, targetGap); - return true; - } - - if (bytesDownloaded > 0 && transcodingPositionTicks > 0) - { - // Progressive Streaming - byte-based consideration - - try - { - var bytesTranscoded = job.BytesTranscoded ?? _fileSystem.GetFileInfo(path).Length; - - // Estimate the bytes the transcoder should be ahead - double gapFactor = gapLengthInTicks; - gapFactor /= transcodingPositionTicks; - var targetGap = bytesTranscoded * gapFactor; - - var gap = bytesTranscoded - bytesDownloaded; - - if (gap < targetGap) - { - _logger.LogDebug("Not throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); - return false; - } - - _logger.LogDebug("Throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting output size"); - return false; - } - } - - _logger.LogDebug("No throttle data for {Path}", path); - return false; - } - - private void DisposeTimer() - { - if (_timer is not null) - { - _timer.Dispose(); - _timer = null; - } - } -} diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs index cc1f9163e5..4b69392ef4 100644 --- a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs +++ b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs @@ -1,6 +1,5 @@ using System; using Jellyfin.Api.Helpers; -using Jellyfin.Api.Models.PlaybackDtos; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dlna; diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJobDto.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJobDto.cs new file mode 100644 index 0000000000..6f929204ff --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingJobDto.cs @@ -0,0 +1,282 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using MediaBrowser.Model.Dto; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// Class TranscodingJob. +/// +public class TranscodingJobDto : IDisposable +{ + /// + /// The process lock. + /// + [SuppressMessage("Microsoft.Performance", "CA1051:NoVisibleInstanceFields", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "SA1401:PrivateField", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] + public readonly object ProcessLock = new object(); + + /// + /// Timer lock. + /// + private readonly object _timerLock = new object(); + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public TranscodingJobDto(ILogger logger) + { + Logger = logger; + } + + /// + /// Gets or sets the play session identifier. + /// + /// The play session identifier. + public string? PlaySessionId { get; set; } + + /// + /// Gets or sets the live stream identifier. + /// + /// The live stream identifier. + public string? LiveStreamId { get; set; } + + /// + /// Gets or sets a value indicating whether is live output. + /// + public bool IsLiveOutput { get; set; } + + /// + /// Gets or sets the path. + /// + /// The path. + public MediaSourceInfo? MediaSource { get; set; } + + /// + /// Gets or sets path. + /// + public string? Path { get; set; } + + /// + /// Gets or sets the type. + /// + /// The type. + public TranscodingJobType Type { get; set; } + + /// + /// Gets or sets the process. + /// + /// The process. + public Process? Process { get; set; } + + /// + /// Gets logger. + /// + public ILogger Logger { get; private set; } + + /// + /// Gets or sets the active request count. + /// + /// The active request count. + public int ActiveRequestCount { get; set; } + + /// + /// Gets or sets the kill timer. + /// + /// The kill timer. + private Timer? KillTimer { get; set; } + + /// + /// Gets or sets device id. + /// + public string? DeviceId { get; set; } + + /// + /// Gets or sets cancellation token source. + /// + public CancellationTokenSource? CancellationTokenSource { get; set; } + + /// + /// Gets or sets a value indicating whether has exited. + /// + public bool HasExited { get; set; } + + /// + /// Gets or sets exit code. + /// + public int ExitCode { get; set; } + + /// + /// Gets or sets a value indicating whether is user paused. + /// + public bool IsUserPaused { get; set; } + + /// + /// Gets or sets id. + /// + public string? Id { get; set; } + + /// + /// Gets or sets framerate. + /// + public float? Framerate { get; set; } + + /// + /// Gets or sets completion percentage. + /// + public double? CompletionPercentage { get; set; } + + /// + /// Gets or sets bytes downloaded. + /// + public long BytesDownloaded { get; set; } + + /// + /// Gets or sets bytes transcoded. + /// + public long? BytesTranscoded { get; set; } + + /// + /// Gets or sets bit rate. + /// + public int? BitRate { get; set; } + + /// + /// Gets or sets transcoding position ticks. + /// + public long? TranscodingPositionTicks { get; set; } + + /// + /// Gets or sets download position ticks. + /// + public long? DownloadPositionTicks { get; set; } + + /// + /// Gets or sets transcoding throttler. + /// + public TranscodingThrottler? TranscodingThrottler { get; set; } + + /// + /// Gets or sets last ping date. + /// + public DateTime LastPingDate { get; set; } + + /// + /// Gets or sets ping timeout. + /// + public int PingTimeout { get; set; } + + /// + /// Stop kill timer. + /// + public void StopKillTimer() + { + lock (_timerLock) + { + KillTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + } + + /// + /// Dispose kill timer. + /// + public void DisposeKillTimer() + { + lock (_timerLock) + { + if (KillTimer is not null) + { + KillTimer.Dispose(); + KillTimer = null; + } + } + } + + /// + /// Start kill timer. + /// + /// Callback action. + public void StartKillTimer(Action callback) + { + StartKillTimer(callback, PingTimeout); + } + + /// + /// Start kill timer. + /// + /// Callback action. + /// Callback interval. + public void StartKillTimer(Action callback, int intervalMs) + { + if (HasExited) + { + return; + } + + lock (_timerLock) + { + if (KillTimer is null) + { + Logger.LogDebug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer = new Timer(new TimerCallback(callback), this, intervalMs, Timeout.Infinite); + } + else + { + Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer.Change(intervalMs, Timeout.Infinite); + } + } + } + + /// + /// Change kill timer if started. + /// + public void ChangeKillTimerIfStarted() + { + if (HasExited) + { + return; + } + + lock (_timerLock) + { + if (KillTimer is not null) + { + var intervalMs = PingTimeout; + + Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer.Change(intervalMs, Timeout.Infinite); + } + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose all resources. + /// + /// Whether to dispose all resources. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + Process?.Dispose(); + Process = null; + KillTimer?.Dispose(); + KillTimer = null; + CancellationTokenSource?.Dispose(); + CancellationTokenSource = null; + TranscodingThrottler?.Dispose(); + TranscodingThrottler = null; + } + } +} diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs new file mode 100644 index 0000000000..aa08af54f0 --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs @@ -0,0 +1,218 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// Transcoding throttler. +/// +public class TranscodingThrottler : IDisposable +{ + private readonly TranscodingJobDto _job; + private readonly ILogger _logger; + private readonly IConfigurationManager _config; + private readonly IFileSystem _fileSystem; + private readonly IMediaEncoder _mediaEncoder; + private Timer? _timer; + private bool _isPaused; + + /// + /// Initializes a new instance of the class. + /// + /// Transcoding job dto. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public TranscodingThrottler(TranscodingJobDto job, ILogger logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder) + { + _job = job; + _logger = logger; + _config = config; + _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + } + + /// + /// Start timer. + /// + public void Start() + { + _timer = new Timer(TimerCallback, null, 5000, 5000); + } + + /// + /// Unpause transcoding. + /// + /// A . + public async Task UnpauseTranscoding() + { + if (_isPaused) + { + _logger.LogDebug("Sending resume command to ffmpeg"); + + try + { + var resumeKey = _mediaEncoder.IsPkeyPauseSupported ? "u" : Environment.NewLine; + await _job.Process!.StandardInput.WriteAsync(resumeKey).ConfigureAwait(false); + _isPaused = false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resuming transcoding"); + } + } + } + + /// + /// Stop throttler. + /// + /// A . + public async Task Stop() + { + DisposeTimer(); + await UnpauseTranscoding().ConfigureAwait(false); + } + + /// + /// Dispose throttler. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose throttler. + /// + /// Disposing. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + DisposeTimer(); + } + } + + private EncodingOptions GetOptions() + { + return _config.GetEncodingOptions(); + } + + private async void TimerCallback(object? state) + { + if (_job.HasExited) + { + DisposeTimer(); + return; + } + + var options = GetOptions(); + + if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds)) + { + await PauseTranscoding().ConfigureAwait(false); + } + else + { + await UnpauseTranscoding().ConfigureAwait(false); + } + } + + private async Task PauseTranscoding() + { + if (!_isPaused) + { + var pauseKey = _mediaEncoder.IsPkeyPauseSupported ? "p" : "c"; + + _logger.LogDebug("Sending pause command [{Key}] to ffmpeg", pauseKey); + + try + { + await _job.Process!.StandardInput.WriteAsync(pauseKey).ConfigureAwait(false); + _isPaused = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error pausing transcoding"); + } + } + } + + private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds) + { + var bytesDownloaded = job.BytesDownloaded; + var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0; + var downloadPositionTicks = job.DownloadPositionTicks ?? 0; + + var path = job.Path ?? throw new ArgumentException("Path can't be null."); + + var gapLengthInTicks = TimeSpan.FromSeconds(thresholdSeconds).Ticks; + + if (downloadPositionTicks > 0 && transcodingPositionTicks > 0) + { + // HLS - time-based consideration + + var targetGap = gapLengthInTicks; + var gap = transcodingPositionTicks - downloadPositionTicks; + + if (gap < targetGap) + { + _logger.LogDebug("Not throttling transcoder gap {0} target gap {1}", gap, targetGap); + return false; + } + + _logger.LogDebug("Throttling transcoder gap {0} target gap {1}", gap, targetGap); + return true; + } + + if (bytesDownloaded > 0 && transcodingPositionTicks > 0) + { + // Progressive Streaming - byte-based consideration + + try + { + var bytesTranscoded = job.BytesTranscoded ?? _fileSystem.GetFileInfo(path).Length; + + // Estimate the bytes the transcoder should be ahead + double gapFactor = gapLengthInTicks; + gapFactor /= transcodingPositionTicks; + var targetGap = bytesTranscoded * gapFactor; + + var gap = bytesTranscoded - bytesDownloaded; + + if (gap < targetGap) + { + _logger.LogDebug("Not throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); + return false; + } + + _logger.LogDebug("Throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting output size"); + return false; + } + } + + _logger.LogDebug("No throttle data for {Path}", path); + return false; + } + + private void DisposeTimer() + { + if (_timer is not null) + { + _timer.Dispose(); + _timer = null; + } + } +} -- cgit v1.2.3 From c2081955c8b2a81eb214f321697d3462709164e0 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 31 Oct 2023 11:31:09 -0400 Subject: Rename and clean up TranscodingJob --- Jellyfin.Api/Controllers/DynamicHlsController.cs | 8 +- Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs | 2 +- Jellyfin.Api/Helpers/ProgressiveFileStream.cs | 4 +- Jellyfin.Api/Helpers/TranscodingJobHelper.cs | 71 ++---- Jellyfin.Api/Models/StreamingDtos/StreamState.cs | 2 +- .../MediaEncoding/TranscodingJob.cs | 280 ++++++++++++++++++++ .../MediaEncoding/TranscodingJobDto.cs | 282 --------------------- .../MediaEncoding/TranscodingThrottler.cs | 6 +- 8 files changed, 313 insertions(+), 342 deletions(-) create mode 100644 MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs delete mode 100644 MediaBrowser.Controller/MediaEncoding/TranscodingJobDto.cs diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 6b2c423632..260ed4787b 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -287,7 +287,7 @@ public class DynamicHlsController : BaseJellyfinApiController cancellationToken) .ConfigureAwait(false); - TranscodingJobDto? job = null; + TranscodingJob? job = null; var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); if (!System.IO.File.Exists(playlistPath)) @@ -1431,7 +1431,7 @@ public class DynamicHlsController : BaseJellyfinApiController var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); - TranscodingJobDto? job; + TranscodingJob? job; if (System.IO.File.Exists(segmentPath)) { @@ -1921,7 +1921,7 @@ public class DynamicHlsController : BaseJellyfinApiController string segmentPath, string segmentExtension, int segmentIndex, - TranscodingJobDto? transcodingJob, + TranscodingJob? transcodingJob, CancellationToken cancellationToken) { var segmentExists = System.IO.File.Exists(segmentPath); @@ -1990,7 +1990,7 @@ public class DynamicHlsController : BaseJellyfinApiController return GetSegmentResult(state, segmentPath, transcodingJob); } - private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJobDto? transcodingJob) + private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJob? transcodingJob) { var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks; diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index ce2e476b7d..fafa2c0551 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -96,7 +96,7 @@ public static class FileStreamResponseHelpers await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); try { - TranscodingJobDto? job; + TranscodingJob? job; if (!File.Exists(outputPath)) { job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, httpContext.Request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false); diff --git a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs index 379ec6e27b..18088483d3 100644 --- a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs +++ b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs @@ -14,7 +14,7 @@ namespace Jellyfin.Api.Helpers; public class ProgressiveFileStream : Stream { private readonly Stream _stream; - private readonly TranscodingJobDto? _job; + private readonly TranscodingJob? _job; private readonly TranscodingJobHelper? _transcodingJobHelper; private readonly int _timeoutMs; private bool _disposed; @@ -26,7 +26,7 @@ public class ProgressiveFileStream : Stream /// The transcoding job information. /// The transcoding job helper. /// The timeout duration in milliseconds. - public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, int timeoutMs = 30000) + public ProgressiveFileStream(string filePath, TranscodingJob? job, TranscodingJobHelper transcodingJobHelper, int timeoutMs = 30000) { _job = job; _transcodingJobHelper = transcodingJobHelper; diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 14436909c9..9a6ec17fdb 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -36,7 +36,7 @@ public class TranscodingJobHelper : IDisposable /// /// The active transcoding jobs. /// - private static readonly List _activeTranscodingJobs = new List(); + private static readonly List _activeTranscodingJobs = new List(); /// /// The transcoding locks. @@ -105,7 +105,7 @@ public class TranscodingJobHelper : IDisposable /// /// Playback session id. /// The transcoding job. - public TranscodingJobDto? GetTranscodingJob(string playSessionId) + public TranscodingJob? GetTranscodingJob(string playSessionId) { lock (_activeTranscodingJobs) { @@ -119,7 +119,7 @@ public class TranscodingJobHelper : IDisposable /// Path to the transcoding file. /// The . /// The transcoding job. - public TranscodingJobDto? GetTranscodingJob(string path, TranscodingJobType type) + public TranscodingJob? GetTranscodingJob(string path, TranscodingJobType type) { lock (_activeTranscodingJobs) { @@ -139,7 +139,7 @@ public class TranscodingJobHelper : IDisposable _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused); - List jobs; + List jobs; lock (_activeTranscodingJobs) { @@ -160,7 +160,7 @@ public class TranscodingJobHelper : IDisposable } } - private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn) + private void PingTimer(TranscodingJob job, bool isProgressCheckIn) { if (job.HasExited) { @@ -195,7 +195,7 @@ public class TranscodingJobHelper : IDisposable /// The state. private async void OnTranscodeKillTimerStopped(object? state) { - var job = state as TranscodingJobDto ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJobDto)}", nameof(state)); + var job = state as TranscodingJob ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJob)}", nameof(state)); if (!job.HasExited && job.Type != TranscodingJobType.Progressive) { var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; @@ -234,9 +234,9 @@ public class TranscodingJobHelper : IDisposable /// The kill job. /// The delete files. /// Task. - private Task KillTranscodingJobs(Func killJob, Func deleteFiles) + private Task KillTranscodingJobs(Func killJob, Func deleteFiles) { - var jobs = new List(); + var jobs = new List(); lock (_activeTranscodingJobs) { @@ -267,7 +267,7 @@ public class TranscodingJobHelper : IDisposable /// The job. /// if set to true [close live stream]. /// The delete. - private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func delete) + private async Task KillTranscodingJob(TranscodingJob job, bool closeLiveStream, Func delete) { job.DisposeKillTimer(); @@ -281,6 +281,7 @@ public class TranscodingJobHelper : IDisposable { #pragma warning disable CA1849 // Can't await in lock block job.CancellationTokenSource.Cancel(); +#pragma warning restore CA1849 } } @@ -289,35 +290,7 @@ public class TranscodingJobHelper : IDisposable _transcodingLocks.Remove(job.Path!); } - lock (job.ProcessLock!) - { - job.TranscodingThrottler?.Stop().GetAwaiter().GetResult(); - - var process = job.Process; - - var hasExited = job.HasExited; - - if (!hasExited) - { - try - { - _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path); - - process!.StandardInput.WriteLine("q"); - - // Need to wait because killing is asynchronous. - if (!process.WaitForExit(5000)) - { - _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path); - process.Kill(); - } - } - catch (InvalidOperationException) - { - } - } -#pragma warning restore CA1849 - } + job.Stop(); if (delete(job.Path!)) { @@ -430,7 +403,7 @@ public class TranscodingJobHelper : IDisposable /// /// Report the transcoding progress to the session manager. /// - /// The of which the progress will be reported. + /// The of which the progress will be reported. /// The of the current transcoding job. /// The current transcoding position. /// The framerate of the transcoding job. @@ -438,7 +411,7 @@ public class TranscodingJobHelper : IDisposable /// The number of bytes transcoded. /// The bitrate of the transcoding job. public void ReportTranscodingProgress( - TranscodingJobDto job, + TranscodingJob job, StreamState state, TimeSpan? transcodingPosition, float? framerate, @@ -500,7 +473,7 @@ public class TranscodingJobHelper : IDisposable /// The cancellation token source. /// The working directory. /// Task. - public async Task StartFfMpeg( + public async Task StartFfMpeg( StreamState state, string outputPath, string commandLineArguments, @@ -655,7 +628,7 @@ public class TranscodingJobHelper : IDisposable return transcodingJob; } - private void StartThrottler(StreamState state, TranscodingJobDto transcodingJob) + private void StartThrottler(StreamState state, TranscodingJob transcodingJob) { if (EnableThrottling(state)) { @@ -688,7 +661,7 @@ public class TranscodingJobHelper : IDisposable /// The state. /// The cancellation token source. /// TranscodingJob. - public TranscodingJobDto OnTranscodeBeginning( + public TranscodingJob OnTranscodeBeginning( string path, string? playSessionId, string? liveStreamId, @@ -701,7 +674,7 @@ public class TranscodingJobHelper : IDisposable { lock (_activeTranscodingJobs) { - var job = new TranscodingJobDto(_loggerFactory.CreateLogger()) + var job = new TranscodingJob(_loggerFactory.CreateLogger()) { Type = type, Path = path, @@ -727,7 +700,7 @@ public class TranscodingJobHelper : IDisposable /// Called when [transcode end]. /// /// The transcode job. - public void OnTranscodeEndRequest(TranscodingJobDto job) + public void OnTranscodeEndRequest(TranscodingJob job) { job.ActiveRequestCount--; _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount); @@ -775,7 +748,7 @@ public class TranscodingJobHelper : IDisposable /// The process. /// The job. /// The state. - private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state) + private void OnFfMpegProcessExited(Process process, TranscodingJob job, StreamState state) { job.HasExited = true; job.ExitCode = process.ExitCode; @@ -826,8 +799,8 @@ public class TranscodingJobHelper : IDisposable /// /// The path. /// The type. - /// The . - public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type) + /// The . + public TranscodingJob? OnTranscodeBeginRequest(string path, TranscodingJobType type) { lock (_activeTranscodingJobs) { @@ -844,7 +817,7 @@ public class TranscodingJobHelper : IDisposable } } - private void OnTranscodeBeginRequest(TranscodingJobDto job) + private void OnTranscodeBeginRequest(TranscodingJob job) { job.ActiveRequestCount++; diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs index 4b69392ef4..439f8052c1 100644 --- a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs +++ b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs @@ -140,7 +140,7 @@ public class StreamState : EncodingJobInfo, IDisposable /// /// Gets or sets the transcoding job. /// - public TranscodingJobDto? TranscodingJob { get; set; } + public TranscodingJob? TranscodingJob { get; set; } /// public void Dispose() diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs new file mode 100644 index 0000000000..1e6d5933c8 --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs @@ -0,0 +1,280 @@ +using System; +using System.Diagnostics; +using System.Threading; +using MediaBrowser.Model.Dto; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// Class TranscodingJob. +/// +public sealed class TranscodingJob : IDisposable +{ + private readonly ILogger _logger; + private readonly object _processLock = new(); + private readonly object _timerLock = new(); + + private Timer? _killTimer; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public TranscodingJob(ILogger logger) + { + _logger = logger; + } + + /// + /// Gets or sets the play session identifier. + /// + public string? PlaySessionId { get; set; } + + /// + /// Gets or sets the live stream identifier. + /// + public string? LiveStreamId { get; set; } + + /// + /// Gets or sets a value indicating whether is live output. + /// + public bool IsLiveOutput { get; set; } + + /// + /// Gets or sets the path. + /// + public MediaSourceInfo? MediaSource { get; set; } + + /// + /// Gets or sets path. + /// + public string? Path { get; set; } + + /// + /// Gets or sets the type. + /// + public TranscodingJobType Type { get; set; } + + /// + /// Gets or sets the process. + /// + public Process? Process { get; set; } + + /// + /// Gets or sets the active request count. + /// + public int ActiveRequestCount { get; set; } + + /// + /// Gets or sets device id. + /// + public string? DeviceId { get; set; } + + /// + /// Gets or sets cancellation token source. + /// + public CancellationTokenSource? CancellationTokenSource { get; set; } + + /// + /// Gets or sets a value indicating whether has exited. + /// + public bool HasExited { get; set; } + + /// + /// Gets or sets exit code. + /// + public int ExitCode { get; set; } + + /// + /// Gets or sets a value indicating whether is user paused. + /// + public bool IsUserPaused { get; set; } + + /// + /// Gets or sets id. + /// + public string? Id { get; set; } + + /// + /// Gets or sets framerate. + /// + public float? Framerate { get; set; } + + /// + /// Gets or sets completion percentage. + /// + public double? CompletionPercentage { get; set; } + + /// + /// Gets or sets bytes downloaded. + /// + public long BytesDownloaded { get; set; } + + /// + /// Gets or sets bytes transcoded. + /// + public long? BytesTranscoded { get; set; } + + /// + /// Gets or sets bit rate. + /// + public int? BitRate { get; set; } + + /// + /// Gets or sets transcoding position ticks. + /// + public long? TranscodingPositionTicks { get; set; } + + /// + /// Gets or sets download position ticks. + /// + public long? DownloadPositionTicks { get; set; } + + /// + /// Gets or sets transcoding throttler. + /// + public TranscodingThrottler? TranscodingThrottler { get; set; } + + /// + /// Gets or sets last ping date. + /// + public DateTime LastPingDate { get; set; } + + /// + /// Gets or sets ping timeout. + /// + public int PingTimeout { get; set; } + + /// + /// Stop kill timer. + /// + public void StopKillTimer() + { + lock (_timerLock) + { + _killTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + } + + /// + /// Dispose kill timer. + /// + public void DisposeKillTimer() + { + lock (_timerLock) + { + if (_killTimer is not null) + { + _killTimer.Dispose(); + _killTimer = null; + } + } + } + + /// + /// Start kill timer. + /// + /// Callback action. + public void StartKillTimer(Action callback) + { + StartKillTimer(callback, PingTimeout); + } + + /// + /// Start kill timer. + /// + /// Callback action. + /// Callback interval. + public void StartKillTimer(Action callback, int intervalMs) + { + if (HasExited) + { + return; + } + + lock (_timerLock) + { + if (_killTimer is null) + { + _logger.LogDebug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + _killTimer = new Timer(new TimerCallback(callback), this, intervalMs, Timeout.Infinite); + } + else + { + _logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + _killTimer.Change(intervalMs, Timeout.Infinite); + } + } + } + + /// + /// Change kill timer if started. + /// + public void ChangeKillTimerIfStarted() + { + if (HasExited) + { + return; + } + + lock (_timerLock) + { + if (_killTimer is not null) + { + var intervalMs = PingTimeout; + + _logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + _killTimer.Change(intervalMs, Timeout.Infinite); + } + } + } + + /// + /// Stops the transcoding job. + /// + public void Stop() + { + lock (_processLock) + { +#pragma warning disable CA1849 // Can't await in lock block + TranscodingThrottler?.Stop().GetAwaiter().GetResult(); + + var process = Process; + + if (!HasExited) + { + try + { + _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", Path); + + process!.StandardInput.WriteLine("q"); + + // Need to wait because killing is asynchronous. + if (!process.WaitForExit(5000)) + { + _logger.LogInformation("Killing FFmpeg process for {Path}", Path); + process.Kill(); + } + } + catch (InvalidOperationException) + { + } + } +#pragma warning restore CA1849 + } + } + + /// + public void Dispose() + { + Process?.Dispose(); + Process = null; + _killTimer?.Dispose(); + _killTimer = null; + CancellationTokenSource?.Dispose(); + CancellationTokenSource = null; + TranscodingThrottler?.Dispose(); + TranscodingThrottler = null; + } +} diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJobDto.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJobDto.cs deleted file mode 100644 index 6f929204ff..0000000000 --- a/MediaBrowser.Controller/MediaEncoding/TranscodingJobDto.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using MediaBrowser.Model.Dto; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Controller.MediaEncoding; - -/// -/// Class TranscodingJob. -/// -public class TranscodingJobDto : IDisposable -{ - /// - /// The process lock. - /// - [SuppressMessage("Microsoft.Performance", "CA1051:NoVisibleInstanceFields", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "SA1401:PrivateField", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] - public readonly object ProcessLock = new object(); - - /// - /// Timer lock. - /// - private readonly object _timerLock = new object(); - - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - public TranscodingJobDto(ILogger logger) - { - Logger = logger; - } - - /// - /// Gets or sets the play session identifier. - /// - /// The play session identifier. - public string? PlaySessionId { get; set; } - - /// - /// Gets or sets the live stream identifier. - /// - /// The live stream identifier. - public string? LiveStreamId { get; set; } - - /// - /// Gets or sets a value indicating whether is live output. - /// - public bool IsLiveOutput { get; set; } - - /// - /// Gets or sets the path. - /// - /// The path. - public MediaSourceInfo? MediaSource { get; set; } - - /// - /// Gets or sets path. - /// - public string? Path { get; set; } - - /// - /// Gets or sets the type. - /// - /// The type. - public TranscodingJobType Type { get; set; } - - /// - /// Gets or sets the process. - /// - /// The process. - public Process? Process { get; set; } - - /// - /// Gets logger. - /// - public ILogger Logger { get; private set; } - - /// - /// Gets or sets the active request count. - /// - /// The active request count. - public int ActiveRequestCount { get; set; } - - /// - /// Gets or sets the kill timer. - /// - /// The kill timer. - private Timer? KillTimer { get; set; } - - /// - /// Gets or sets device id. - /// - public string? DeviceId { get; set; } - - /// - /// Gets or sets cancellation token source. - /// - public CancellationTokenSource? CancellationTokenSource { get; set; } - - /// - /// Gets or sets a value indicating whether has exited. - /// - public bool HasExited { get; set; } - - /// - /// Gets or sets exit code. - /// - public int ExitCode { get; set; } - - /// - /// Gets or sets a value indicating whether is user paused. - /// - public bool IsUserPaused { get; set; } - - /// - /// Gets or sets id. - /// - public string? Id { get; set; } - - /// - /// Gets or sets framerate. - /// - public float? Framerate { get; set; } - - /// - /// Gets or sets completion percentage. - /// - public double? CompletionPercentage { get; set; } - - /// - /// Gets or sets bytes downloaded. - /// - public long BytesDownloaded { get; set; } - - /// - /// Gets or sets bytes transcoded. - /// - public long? BytesTranscoded { get; set; } - - /// - /// Gets or sets bit rate. - /// - public int? BitRate { get; set; } - - /// - /// Gets or sets transcoding position ticks. - /// - public long? TranscodingPositionTicks { get; set; } - - /// - /// Gets or sets download position ticks. - /// - public long? DownloadPositionTicks { get; set; } - - /// - /// Gets or sets transcoding throttler. - /// - public TranscodingThrottler? TranscodingThrottler { get; set; } - - /// - /// Gets or sets last ping date. - /// - public DateTime LastPingDate { get; set; } - - /// - /// Gets or sets ping timeout. - /// - public int PingTimeout { get; set; } - - /// - /// Stop kill timer. - /// - public void StopKillTimer() - { - lock (_timerLock) - { - KillTimer?.Change(Timeout.Infinite, Timeout.Infinite); - } - } - - /// - /// Dispose kill timer. - /// - public void DisposeKillTimer() - { - lock (_timerLock) - { - if (KillTimer is not null) - { - KillTimer.Dispose(); - KillTimer = null; - } - } - } - - /// - /// Start kill timer. - /// - /// Callback action. - public void StartKillTimer(Action callback) - { - StartKillTimer(callback, PingTimeout); - } - - /// - /// Start kill timer. - /// - /// Callback action. - /// Callback interval. - public void StartKillTimer(Action callback, int intervalMs) - { - if (HasExited) - { - return; - } - - lock (_timerLock) - { - if (KillTimer is null) - { - Logger.LogDebug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer = new Timer(new TimerCallback(callback), this, intervalMs, Timeout.Infinite); - } - else - { - Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer.Change(intervalMs, Timeout.Infinite); - } - } - } - - /// - /// Change kill timer if started. - /// - public void ChangeKillTimerIfStarted() - { - if (HasExited) - { - return; - } - - lock (_timerLock) - { - if (KillTimer is not null) - { - var intervalMs = PingTimeout; - - Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer.Change(intervalMs, Timeout.Infinite); - } - } - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Dispose all resources. - /// - /// Whether to dispose all resources. - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - Process?.Dispose(); - Process = null; - KillTimer?.Dispose(); - KillTimer = null; - CancellationTokenSource?.Dispose(); - CancellationTokenSource = null; - TranscodingThrottler?.Dispose(); - TranscodingThrottler = null; - } - } -} diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs index aa08af54f0..813f13eaef 100644 --- a/MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingThrottler.cs @@ -13,7 +13,7 @@ namespace MediaBrowser.Controller.MediaEncoding; /// public class TranscodingThrottler : IDisposable { - private readonly TranscodingJobDto _job; + private readonly TranscodingJob _job; private readonly ILogger _logger; private readonly IConfigurationManager _config; private readonly IFileSystem _fileSystem; @@ -29,7 +29,7 @@ public class TranscodingThrottler : IDisposable /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - public TranscodingThrottler(TranscodingJobDto job, ILogger logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder) + public TranscodingThrottler(TranscodingJob job, ILogger logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder) { _job = job; _logger = logger; @@ -145,7 +145,7 @@ public class TranscodingThrottler : IDisposable } } - private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds) + private bool IsThrottleAllowed(TranscodingJob job, int thresholdSeconds) { var bytesDownloaded = job.BytesDownloaded; var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0; -- cgit v1.2.3 From 9215a4d40ae24e5996a5e16dfa296b09a7befc40 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Tue, 31 Oct 2023 13:26:37 -0400 Subject: Add ITranscodeManager service --- Emby.Server.Implementations/ApplicationHost.cs | 3 +- Jellyfin.Api/Controllers/AudioController.cs | 1 + Jellyfin.Api/Controllers/DynamicHlsController.cs | 46 +- Jellyfin.Api/Controllers/HlsSegmentController.cs | 14 +- Jellyfin.Api/Controllers/LiveTvController.cs | 11 +- Jellyfin.Api/Controllers/PlaystateController.cs | 17 +- .../Controllers/UniversalAudioController.cs | 1 + Jellyfin.Api/Controllers/VideosController.cs | 16 +- Jellyfin.Api/Helpers/AudioHelper.cs | 16 +- Jellyfin.Api/Helpers/DynamicHlsHelper.cs | 12 +- Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs | 21 +- Jellyfin.Api/Helpers/HlsHelpers.cs | 1 + Jellyfin.Api/Helpers/ProgressiveFileStream.cs | 12 +- Jellyfin.Api/Helpers/StreamingHelpers.cs | 10 +- Jellyfin.Api/Helpers/TranscodingJobHelper.cs | 896 --------------------- .../Models/StreamingDtos/HlsAudioRequestDto.cs | 4 +- .../Models/StreamingDtos/HlsVideoRequestDto.cs | 4 +- Jellyfin.Api/Models/StreamingDtos/StreamState.cs | 184 ----- .../Models/StreamingDtos/StreamingRequestDto.cs | 49 -- .../Models/StreamingDtos/VideoRequestDto.cs | 23 - .../MediaEncoding/ITranscodeManager.cs | 104 +++ MediaBrowser.Controller/Streaming/StreamState.cs | 183 +++++ .../Streaming/StreamingRequestDto.cs | 49 ++ .../Streaming/VideoRequestDto.cs | 23 + .../Transcoding/TranscodeManager.cs | 750 +++++++++++++++++ 25 files changed, 1213 insertions(+), 1237 deletions(-) delete mode 100644 Jellyfin.Api/Helpers/TranscodingJobHelper.cs delete mode 100644 Jellyfin.Api/Models/StreamingDtos/StreamState.cs delete mode 100644 Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs delete mode 100644 Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs create mode 100644 MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs create mode 100644 MediaBrowser.Controller/Streaming/StreamState.cs create mode 100644 MediaBrowser.Controller/Streaming/StreamingRequestDto.cs create mode 100644 MediaBrowser.Controller/Streaming/VideoRequestDto.cs create mode 100644 MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index dce56e0a4f..f385f6a512 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -76,6 +76,7 @@ using MediaBrowser.Controller.TV; using MediaBrowser.LocalMetadata.Savers; using MediaBrowser.MediaEncoding.BdInfo; using MediaBrowser.MediaEncoding.Subtitles; +using MediaBrowser.MediaEncoding.Transcoding; using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; @@ -583,7 +584,7 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index 5bc5330861..cd09d2bfab 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -6,6 +6,7 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; using MediaBrowser.Model.Dlna; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 260ed4787b..dda1e9d561 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -9,6 +9,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; +using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Data.Enums; @@ -18,6 +19,7 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; @@ -50,7 +52,7 @@ public class DynamicHlsController : BaseJellyfinApiController private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IMediaEncoder _mediaEncoder; private readonly IFileSystem _fileSystem; - private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ITranscodeManager _transcodeManager; private readonly ILogger _logger; private readonly EncodingHelper _encodingHelper; private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator; @@ -66,7 +68,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Instance of the class. + /// Instance of the interface. /// Instance of the interface. /// Instance of . /// Instance of . @@ -78,7 +80,7 @@ public class DynamicHlsController : BaseJellyfinApiController IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, IFileSystem fileSystem, - TranscodingJobHelper transcodingJobHelper, + ITranscodeManager transcodeManager, ILogger logger, DynamicHlsHelper dynamicHlsHelper, EncodingHelper encodingHelper, @@ -90,7 +92,7 @@ public class DynamicHlsController : BaseJellyfinApiController _serverConfigurationManager = serverConfigurationManager; _mediaEncoder = mediaEncoder; _fileSystem = fileSystem; - _transcodingJobHelper = transcodingJobHelper; + _transcodeManager = transcodeManager; _logger = logger; _dynamicHlsHelper = dynamicHlsHelper; _encodingHelper = encodingHelper; @@ -282,7 +284,7 @@ public class DynamicHlsController : BaseJellyfinApiController _serverConfigurationManager, _mediaEncoder, _encodingHelper, - _transcodingJobHelper, + _transcodeManager, TranscodingJobType, cancellationToken) .ConfigureAwait(false); @@ -292,7 +294,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (!System.IO.File.Exists(playlistPath)) { - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); + var transcodingLock = _transcodeManager.GetTranscodingLock(playlistPath); await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { @@ -301,11 +303,11 @@ public class DynamicHlsController : BaseJellyfinApiController // If the playlist doesn't already exist, startup ffmpeg try { - job = await _transcodingJobHelper.StartFfMpeg( + job = await _transcodeManager.StartFfMpeg( state, playlistPath, GetCommandLineArguments(playlistPath, state, true, 0), - Request, + Request.HttpContext.User.GetUserId(), TranscodingJobType, cancellationTokenSource) .ConfigureAwait(false); @@ -330,11 +332,11 @@ public class DynamicHlsController : BaseJellyfinApiController } } - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); if (job is not null) { - _transcodingJobHelper.OnTranscodeEndRequest(job); + _transcodeManager.OnTranscodeEndRequest(job); } var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); @@ -1382,7 +1384,7 @@ public class DynamicHlsController : BaseJellyfinApiController _serverConfigurationManager, _mediaEncoder, _encodingHelper, - _transcodingJobHelper, + _transcodeManager, TranscodingJobType, cancellationTokenSource.Token) .ConfigureAwait(false); @@ -1420,7 +1422,7 @@ public class DynamicHlsController : BaseJellyfinApiController _serverConfigurationManager, _mediaEncoder, _encodingHelper, - _transcodingJobHelper, + _transcodeManager, TranscodingJobType, cancellationToken) .ConfigureAwait(false); @@ -1435,12 +1437,12 @@ public class DynamicHlsController : BaseJellyfinApiController if (System.IO.File.Exists(segmentPath)) { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); + var transcodingLock = _transcodeManager.GetTranscodingLock(playlistPath); await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); var released = false; var startTranscoding = false; @@ -1449,7 +1451,7 @@ public class DynamicHlsController : BaseJellyfinApiController { if (System.IO.File.Exists(segmentPath)) { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); transcodingLock.Release(); released = true; _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); @@ -1487,7 +1489,7 @@ public class DynamicHlsController : BaseJellyfinApiController // If the playlist doesn't already exist, startup ffmpeg try { - await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) + await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) .ConfigureAwait(false); if (currentTranscodingIndex.HasValue) @@ -1498,11 +1500,11 @@ public class DynamicHlsController : BaseJellyfinApiController streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; state.WaitForPath = segmentPath; - job = await _transcodingJobHelper.StartFfMpeg( + job = await _transcodeManager.StartFfMpeg( state, playlistPath, GetCommandLineArguments(playlistPath, state, false, segmentId), - Request, + Request.HttpContext.User.GetUserId(), TranscodingJobType, cancellationTokenSource).ConfigureAwait(false); } @@ -1516,7 +1518,7 @@ public class DynamicHlsController : BaseJellyfinApiController } else { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); if (job?.TranscodingThrottler is not null) { await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); @@ -1533,7 +1535,7 @@ public class DynamicHlsController : BaseJellyfinApiController } _logger.LogDebug("returning {0} [general case]", segmentPath); - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } @@ -2000,7 +2002,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (transcodingJob is not null) { transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks); - _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); + _transcodeManager.OnTranscodeEndRequest(transcodingJob); } return Task.CompletedTask; @@ -2011,7 +2013,7 @@ public class DynamicHlsController : BaseJellyfinApiController private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) { - var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType); + var job = _transcodeManager.GetTranscodingJob(playlist, TranscodingJobType); if (job is null || job.HasExited) { diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index 392d9955fb..1927a332b2 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -24,22 +24,22 @@ public class HlsSegmentController : BaseJellyfinApiController { private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ITranscodeManager _transcodeManager; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. - /// Initialized instance of the . + /// Instance of the interface. public HlsSegmentController( IFileSystem fileSystem, IServerConfigurationManager serverConfigurationManager, - TranscodingJobHelper transcodingJobHelper) + ITranscodeManager transcodeManager) { _fileSystem = fileSystem; _serverConfigurationManager = serverConfigurationManager; - _transcodingJobHelper = transcodingJobHelper; + _transcodeManager = transcodeManager; } /// @@ -112,7 +112,7 @@ public class HlsSegmentController : BaseJellyfinApiController [FromQuery, Required] string deviceId, [FromQuery, Required] string playSessionId) { - _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true); + _transcodeManager.KillTranscodingJobs(deviceId, playSessionId, _ => true); return NoContent(); } @@ -174,13 +174,13 @@ public class HlsSegmentController : BaseJellyfinApiController private ActionResult GetFileResult(string path, string playlistPath) { - var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); + var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); Response.OnCompleted(() => { if (transcodingJob is not null) { - _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); + _transcodeManager.OnTranscodeEndRequest(transcodingJob); } return Task.CompletedTask; diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 425086895b..a40f273ae4 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -24,6 +24,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; @@ -47,7 +48,7 @@ public class LiveTvController : BaseJellyfinApiController private readonly IDtoService _dtoService; private readonly IMediaSourceManager _mediaSourceManager; private readonly IConfigurationManager _configurationManager; - private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ITranscodeManager _transcodeManager; /// /// Initializes a new instance of the class. @@ -59,7 +60,7 @@ public class LiveTvController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Instance of the class. + /// Instance of the interface. public LiveTvController( ILiveTvManager liveTvManager, IUserManager userManager, @@ -68,7 +69,7 @@ public class LiveTvController : BaseJellyfinApiController IDtoService dtoService, IMediaSourceManager mediaSourceManager, IConfigurationManager configurationManager, - TranscodingJobHelper transcodingJobHelper) + ITranscodeManager transcodeManager) { _liveTvManager = liveTvManager; _userManager = userManager; @@ -77,7 +78,7 @@ public class LiveTvController : BaseJellyfinApiController _dtoService = dtoService; _mediaSourceManager = mediaSourceManager; _configurationManager = configurationManager; - _transcodingJobHelper = transcodingJobHelper; + _transcodeManager = transcodeManager; } /// @@ -1171,7 +1172,7 @@ public class LiveTvController : BaseJellyfinApiController return NotFound(); } - var stream = new ProgressiveFileStream(path, null, _transcodingJobHelper); + var stream = new ProgressiveFileStream(path, null, _transcodeManager); return new FileStreamResult(stream, MimeTypes.GetMimeType(path)); } diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 8ad553bcb8..bde2f4d1ac 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -8,6 +8,7 @@ using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Session; @@ -30,7 +31,7 @@ public class PlaystateController : BaseJellyfinApiController private readonly ILibraryManager _libraryManager; private readonly ISessionManager _sessionManager; private readonly ILogger _logger; - private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ITranscodeManager _transcodeManager; /// /// Initializes a new instance of the class. @@ -40,14 +41,14 @@ public class PlaystateController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Th singleton. + /// Instance of the interface. public PlaystateController( IUserManager userManager, IUserDataManager userDataRepository, ILibraryManager libraryManager, ISessionManager sessionManager, ILoggerFactory loggerFactory, - TranscodingJobHelper transcodingJobHelper) + ITranscodeManager transcodeManager) { _userManager = userManager; _userDataRepository = userDataRepository; @@ -55,7 +56,7 @@ public class PlaystateController : BaseJellyfinApiController _sessionManager = sessionManager; _logger = loggerFactory.CreateLogger(); - _transcodingJobHelper = transcodingJobHelper; + _transcodeManager = transcodeManager; } /// @@ -188,7 +189,7 @@ public class PlaystateController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult PingPlaybackSession([FromQuery, Required] string playSessionId) { - _transcodingJobHelper.PingTranscodingJob(playSessionId, null); + _transcodeManager.PingTranscodingJob(playSessionId, null); return NoContent(); } @@ -205,7 +206,7 @@ public class PlaystateController : BaseJellyfinApiController _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) { - await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); + await _transcodeManager.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); } playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); @@ -354,7 +355,7 @@ public class PlaystateController : BaseJellyfinApiController _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) { - await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); + await _transcodeManager.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); } playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); @@ -388,7 +389,7 @@ public class PlaystateController : BaseJellyfinApiController { if (method == PlayMethod.Transcode) { - var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId); + var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodeManager.GetTranscodingJob(playSessionId); if (job is null) { return PlayMethod.DirectPlay; diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index 7177a04403..0a416aedbe 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -11,6 +11,7 @@ using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.MediaInfo; using Microsoft.AspNetCore.Authorization; diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 5d9868eb9f..c231c147fc 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -11,7 +11,6 @@ using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Api; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; @@ -20,6 +19,7 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -43,7 +43,7 @@ public class VideosController : BaseJellyfinApiController private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IMediaEncoder _mediaEncoder; - private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ITranscodeManager _transcodeManager; private readonly IHttpClientFactory _httpClientFactory; private readonly EncodingHelper _encodingHelper; @@ -58,7 +58,7 @@ public class VideosController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Instance of the class. + /// Instance of the interface. /// Instance of the interface. /// Instance of . public VideosController( @@ -68,7 +68,7 @@ public class VideosController : BaseJellyfinApiController IMediaSourceManager mediaSourceManager, IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, - TranscodingJobHelper transcodingJobHelper, + ITranscodeManager transcodeManager, IHttpClientFactory httpClientFactory, EncodingHelper encodingHelper) { @@ -78,7 +78,7 @@ public class VideosController : BaseJellyfinApiController _mediaSourceManager = mediaSourceManager; _serverConfigurationManager = serverConfigurationManager; _mediaEncoder = mediaEncoder; - _transcodingJobHelper = transcodingJobHelper; + _transcodeManager = transcodeManager; _httpClientFactory = httpClientFactory; _encodingHelper = encodingHelper; } @@ -427,7 +427,7 @@ public class VideosController : BaseJellyfinApiController _serverConfigurationManager, _mediaEncoder, _encodingHelper, - _transcodingJobHelper, + _transcodeManager, _transcodingJobType, cancellationTokenSource.Token) .ConfigureAwait(false); @@ -466,7 +466,7 @@ public class VideosController : BaseJellyfinApiController if (state.MediaSource.IsInfiniteStream) { - var liveStream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); + var liveStream = new ProgressiveFileStream(state.MediaPath, null, _transcodeManager); return File(liveStream, contentType); } @@ -482,7 +482,7 @@ public class VideosController : BaseJellyfinApiController state, isHeadRequest, HttpContext, - _transcodingJobHelper, + _transcodeManager, ffmpegCommandLineArguments, _transcodingJobType, cancellationTokenSource).ConfigureAwait(false); diff --git a/Jellyfin.Api/Helpers/AudioHelper.cs b/Jellyfin.Api/Helpers/AudioHelper.cs index 926ce99dd8..c80a9d582d 100644 --- a/Jellyfin.Api/Helpers/AudioHelper.cs +++ b/Jellyfin.Api/Helpers/AudioHelper.cs @@ -2,13 +2,13 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Http; @@ -26,7 +26,7 @@ public class AudioHelper private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IMediaEncoder _mediaEncoder; - private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ITranscodeManager _transcodeManager; private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpContextAccessor _httpContextAccessor; private readonly EncodingHelper _encodingHelper; @@ -39,7 +39,7 @@ public class AudioHelper /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Instance of . + /// Instance of interface. /// Instance of the interface. /// Instance of the interface. /// Instance of . @@ -49,7 +49,7 @@ public class AudioHelper IMediaSourceManager mediaSourceManager, IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, - TranscodingJobHelper transcodingJobHelper, + ITranscodeManager transcodeManager, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, EncodingHelper encodingHelper) @@ -59,7 +59,7 @@ public class AudioHelper _mediaSourceManager = mediaSourceManager; _serverConfigurationManager = serverConfigurationManager; _mediaEncoder = mediaEncoder; - _transcodingJobHelper = transcodingJobHelper; + _transcodeManager = transcodeManager; _httpClientFactory = httpClientFactory; _httpContextAccessor = httpContextAccessor; _encodingHelper = encodingHelper; @@ -94,7 +94,7 @@ public class AudioHelper _serverConfigurationManager, _mediaEncoder, _encodingHelper, - _transcodingJobHelper, + _transcodeManager, transcodingJobType, cancellationTokenSource.Token) .ConfigureAwait(false); @@ -133,7 +133,7 @@ public class AudioHelper if (state.MediaSource.IsInfiniteStream) { - var stream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); + var stream = new ProgressiveFileStream(state.MediaPath, null, _transcodeManager); return new FileStreamResult(stream, contentType); } @@ -149,7 +149,7 @@ public class AudioHelper state, isHeadRequest, _httpContextAccessor.HttpContext, - _transcodingJobHelper, + _transcodeManager, ffmpegCommandLineArguments, transcodingJobType, cancellationTokenSource).ConfigureAwait(false); diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index 05f7d44bf0..fa81fc284d 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -8,7 +8,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -18,6 +17,7 @@ using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; @@ -39,7 +39,7 @@ public class DynamicHlsHelper private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IMediaEncoder _mediaEncoder; - private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ITranscodeManager _transcodeManager; private readonly INetworkManager _networkManager; private readonly ILogger _logger; private readonly IHttpContextAccessor _httpContextAccessor; @@ -54,7 +54,7 @@ public class DynamicHlsHelper /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Instance of . + /// Instance of . /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. @@ -66,7 +66,7 @@ public class DynamicHlsHelper IMediaSourceManager mediaSourceManager, IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, - TranscodingJobHelper transcodingJobHelper, + ITranscodeManager transcodeManager, INetworkManager networkManager, ILogger logger, IHttpContextAccessor httpContextAccessor, @@ -78,7 +78,7 @@ public class DynamicHlsHelper _mediaSourceManager = mediaSourceManager; _serverConfigurationManager = serverConfigurationManager; _mediaEncoder = mediaEncoder; - _transcodingJobHelper = transcodingJobHelper; + _transcodeManager = transcodeManager; _networkManager = networkManager; _logger = logger; _httpContextAccessor = httpContextAccessor; @@ -130,7 +130,7 @@ public class DynamicHlsHelper _serverConfigurationManager, _mediaEncoder, _encodingHelper, - _transcodingJobHelper, + _transcodeManager, transcodingJobType, cancellationTokenSource.Token) .ConfigureAwait(false); diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index fafa2c0551..5385979d4a 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -4,8 +4,9 @@ using System.Net.Http; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Models.StreamingDtos; +using Jellyfin.Api.Extensions; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; @@ -64,7 +65,7 @@ public static class FileStreamResponseHelpers /// The current . /// Whether the current request is a HTTP HEAD request so only the headers get returned. /// The current http context. - /// The singleton. + /// The singleton. /// The command line arguments to start ffmpeg. /// The . /// The . @@ -73,7 +74,7 @@ public static class FileStreamResponseHelpers StreamState state, bool isHeadRequest, HttpContext httpContext, - TranscodingJobHelper transcodingJobHelper, + ITranscodeManager transcodeManager, string ffmpegCommandLineArguments, TranscodingJobType transcodingJobType, CancellationTokenSource cancellationTokenSource) @@ -92,22 +93,28 @@ public static class FileStreamResponseHelpers return new OkResult(); } - var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath); + var transcodingLock = transcodeManager.GetTranscodingLock(outputPath); await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); try { TranscodingJob? job; if (!File.Exists(outputPath)) { - job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, httpContext.Request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false); + job = await transcodeManager.StartFfMpeg( + state, + outputPath, + ffmpegCommandLineArguments, + httpContext.User.GetUserId(), + transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); } else { - job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive); + job = transcodeManager.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive); state.Dispose(); } - var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper); + var stream = new ProgressiveFileStream(outputPath, job, transcodeManager); return new FileStreamResult(stream, contentType); } finally diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs index e2d3bfb193..c8a36c562e 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; diff --git a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs index 18088483d3..98ea844a96 100644 --- a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs +++ b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs @@ -15,7 +15,7 @@ public class ProgressiveFileStream : Stream { private readonly Stream _stream; private readonly TranscodingJob? _job; - private readonly TranscodingJobHelper? _transcodingJobHelper; + private readonly ITranscodeManager? _transcodeManager; private readonly int _timeoutMs; private bool _disposed; @@ -24,12 +24,12 @@ public class ProgressiveFileStream : Stream /// /// The path to the transcoded file. /// The transcoding job information. - /// The transcoding job helper. + /// The transcode manager. /// The timeout duration in milliseconds. - public ProgressiveFileStream(string filePath, TranscodingJob? job, TranscodingJobHelper transcodingJobHelper, int timeoutMs = 30000) + public ProgressiveFileStream(string filePath, TranscodingJob? job, ITranscodeManager transcodeManager, int timeoutMs = 30000) { _job = job; - _transcodingJobHelper = transcodingJobHelper; + _transcodeManager = transcodeManager; _timeoutMs = timeoutMs; _stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); @@ -43,7 +43,7 @@ public class ProgressiveFileStream : Stream public ProgressiveFileStream(Stream stream, int timeoutMs = 30000) { _job = null; - _transcodingJobHelper = null; + _transcodeManager = null; _timeoutMs = timeoutMs; _stream = stream; } @@ -153,7 +153,7 @@ public class ProgressiveFileStream : Stream if (_job is not null) { - _transcodingJobHelper?.OnTranscodeEndRequest(_job); + _transcodeManager?.OnTranscodeEndRequest(_job); } } } diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 71c62b2356..78943f7b58 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; @@ -14,6 +13,7 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -38,7 +38,7 @@ public static class StreamingHelpers /// Instance of the interface. /// Instance of the interface. /// Instance of . - /// Initialized . + /// Instance of the interface. /// The . /// The . /// A containing the current . @@ -51,7 +51,7 @@ public static class StreamingHelpers IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, EncodingHelper encodingHelper, - TranscodingJobHelper transcodingJobHelper, + ITranscodeManager transcodeManager, TranscodingJobType transcodingJobType, CancellationToken cancellationToken) { @@ -74,7 +74,7 @@ public static class StreamingHelpers streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url); } - var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper) + var state = new StreamState(mediaSourceManager, transcodingJobType, transcodeManager) { Request = streamingRequest, RequestedUrl = url, @@ -115,7 +115,7 @@ public static class StreamingHelpers if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)) { var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId) - ? transcodingJobHelper.GetTranscodingJob(streamingRequest.PlaySessionId) + ? transcodeManager.GetTranscodingJob(streamingRequest.PlaySessionId) : null; if (currentJob is not null) diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs deleted file mode 100644 index 9a6ec17fdb..0000000000 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ /dev/null @@ -1,896 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Api.Extensions; -using Jellyfin.Api.Models.StreamingDtos; -using Jellyfin.Data.Enums; -using MediaBrowser.Common; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Session; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Api.Helpers; - -/// -/// Transcoding job helpers. -/// -public class TranscodingJobHelper : IDisposable -{ - /// - /// The active transcoding jobs. - /// - private static readonly List _activeTranscodingJobs = new List(); - - /// - /// The transcoding locks. - /// - private static readonly Dictionary _transcodingLocks = new Dictionary(); - - private readonly IAttachmentExtractor _attachmentExtractor; - private readonly IApplicationPaths _appPaths; - private readonly EncodingHelper _encodingHelper; - private readonly IFileSystem _fileSystem; - private readonly ILogger _logger; - private readonly IMediaEncoder _mediaEncoder; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly ISessionManager _sessionManager; - private readonly ILoggerFactory _loggerFactory; - private readonly IUserManager _userManager; - - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of . - /// Instance of the interface. - /// Instance of the interface. - public TranscodingJobHelper( - IAttachmentExtractor attachmentExtractor, - IApplicationPaths appPaths, - ILogger logger, - IMediaSourceManager mediaSourceManager, - IFileSystem fileSystem, - IMediaEncoder mediaEncoder, - IServerConfigurationManager serverConfigurationManager, - ISessionManager sessionManager, - EncodingHelper encodingHelper, - ILoggerFactory loggerFactory, - IUserManager userManager) - { - _attachmentExtractor = attachmentExtractor; - _appPaths = appPaths; - _logger = logger; - _mediaSourceManager = mediaSourceManager; - _fileSystem = fileSystem; - _mediaEncoder = mediaEncoder; - _serverConfigurationManager = serverConfigurationManager; - _sessionManager = sessionManager; - _encodingHelper = encodingHelper; - _loggerFactory = loggerFactory; - _userManager = userManager; - - DeleteEncodedMediaCache(); - - sessionManager.PlaybackProgress += OnPlaybackProgress; - sessionManager.PlaybackStart += OnPlaybackProgress; - } - - /// - /// Get transcoding job. - /// - /// Playback session id. - /// The transcoding job. - public TranscodingJob? GetTranscodingJob(string playSessionId) - { - lock (_activeTranscodingJobs) - { - return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase)); - } - } - - /// - /// Get transcoding job. - /// - /// Path to the transcoding file. - /// The . - /// The transcoding job. - public TranscodingJob? GetTranscodingJob(string path, TranscodingJobType type) - { - lock (_activeTranscodingJobs) - { - return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - } - } - - /// - /// Ping transcoding job. - /// - /// Play session id. - /// Is user paused. - /// Play session id is null. - public void PingTranscodingJob(string playSessionId, bool? isUserPaused) - { - ArgumentException.ThrowIfNullOrEmpty(playSessionId); - - _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused); - - List jobs; - - lock (_activeTranscodingJobs) - { - // This is really only needed for HLS. - // Progressive streams can stop on their own reliably. - jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - foreach (var job in jobs) - { - if (isUserPaused.HasValue) - { - _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id); - job.IsUserPaused = isUserPaused.Value; - } - - PingTimer(job, true); - } - } - - private void PingTimer(TranscodingJob job, bool isProgressCheckIn) - { - if (job.HasExited) - { - job.StopKillTimer(); - return; - } - - var timerDuration = 10000; - - if (job.Type != TranscodingJobType.Progressive) - { - timerDuration = 60000; - } - - job.PingTimeout = timerDuration; - job.LastPingDate = DateTime.UtcNow; - - // Don't start the timer for playback checkins with progressive streaming - if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn) - { - job.StartKillTimer(OnTranscodeKillTimerStopped); - } - else - { - job.ChangeKillTimerIfStarted(); - } - } - - /// - /// Called when [transcode kill timer stopped]. - /// - /// The state. - private async void OnTranscodeKillTimerStopped(object? state) - { - var job = state as TranscodingJob ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJob)}", nameof(state)); - if (!job.HasExited && job.Type != TranscodingJobType.Progressive) - { - var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; - - if (timeSinceLastPing < job.PingTimeout) - { - job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout); - return; - } - } - - _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); - - await KillTranscodingJob(job, true, path => true).ConfigureAwait(false); - } - - /// - /// Kills the single transcoding job. - /// - /// The device id. - /// The play session identifier. - /// The delete files. - /// Task. - public Task KillTranscodingJobs(string deviceId, string? playSessionId, Func deleteFiles) - { - return KillTranscodingJobs( - j => string.IsNullOrWhiteSpace(playSessionId) - ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase) - : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), - deleteFiles); - } - - /// - /// Kills the transcoding jobs. - /// - /// The kill job. - /// The delete files. - /// Task. - private Task KillTranscodingJobs(Func killJob, Func deleteFiles) - { - var jobs = new List(); - - lock (_activeTranscodingJobs) - { - // This is really only needed for HLS. - // Progressive streams can stop on their own reliably. - jobs.AddRange(_activeTranscodingJobs.Where(killJob)); - } - - if (jobs.Count == 0) - { - return Task.CompletedTask; - } - - IEnumerable GetKillJobs() - { - foreach (var job in jobs) - { - yield return KillTranscodingJob(job, false, deleteFiles); - } - } - - return Task.WhenAll(GetKillJobs()); - } - - /// - /// Kills the transcoding job. - /// - /// The job. - /// if set to true [close live stream]. - /// The delete. - private async Task KillTranscodingJob(TranscodingJob job, bool closeLiveStream, Func delete) - { - job.DisposeKillTimer(); - - _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); - - lock (_activeTranscodingJobs) - { - _activeTranscodingJobs.Remove(job); - - if (job.CancellationTokenSource?.IsCancellationRequested == false) - { -#pragma warning disable CA1849 // Can't await in lock block - job.CancellationTokenSource.Cancel(); -#pragma warning restore CA1849 - } - } - - lock (_transcodingLocks) - { - _transcodingLocks.Remove(job.Path!); - } - - job.Stop(); - - if (delete(job.Path!)) - { - await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false); - if (job.MediaSource?.VideoType == VideoType.Dvd || job.MediaSource?.VideoType == VideoType.BluRay) - { - var concatFilePath = Path.Join(_serverConfigurationManager.GetTranscodePath(), job.MediaSource.Id + ".concat"); - if (File.Exists(concatFilePath)) - { - _logger.LogInformation("Deleting ffmpeg concat configuration at {Path}", concatFilePath); - File.Delete(concatFilePath); - } - } - } - - if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) - { - try - { - await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error closing live stream for {Path}", job.Path); - } - } - } - - private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs) - { - if (retryCount >= 10) - { - return; - } - - _logger.LogInformation("Deleting partial stream file(s) {Path}", path); - - await Task.Delay(delayMs).ConfigureAwait(false); - - try - { - if (jobType == TranscodingJobType.Progressive) - { - DeleteProgressivePartialStreamFiles(path); - } - else - { - DeleteHlsPartialStreamFiles(path); - } - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - - await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - } - } - - /// - /// Deletes the progressive partial stream files. - /// - /// The output file path. - private void DeleteProgressivePartialStreamFiles(string outputFilePath) - { - if (File.Exists(outputFilePath)) - { - _fileSystem.DeleteFile(outputFilePath); - } - } - - /// - /// Deletes the HLS partial stream files. - /// - /// The output file path. - private void DeleteHlsPartialStreamFiles(string outputFilePath) - { - var directory = Path.GetDirectoryName(outputFilePath) - ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath)); - - var name = Path.GetFileNameWithoutExtension(outputFilePath); - - var filesToDelete = _fileSystem.GetFilePaths(directory) - .Where(f => f.Contains(name, StringComparison.OrdinalIgnoreCase)); - - List? exs = null; - foreach (var file in filesToDelete) - { - try - { - _logger.LogDebug("Deleting HLS file {0}", file); - _fileSystem.DeleteFile(file); - } - catch (IOException ex) - { - (exs ??= new List(4)).Add(ex); - _logger.LogError(ex, "Error deleting HLS file {Path}", file); - } - } - - if (exs is not null) - { - throw new AggregateException("Error deleting HLS files", exs); - } - } - - /// - /// Report the transcoding progress to the session manager. - /// - /// The of which the progress will be reported. - /// The of the current transcoding job. - /// The current transcoding position. - /// The framerate of the transcoding job. - /// The completion percentage of the transcode. - /// The number of bytes transcoded. - /// The bitrate of the transcoding job. - public void ReportTranscodingProgress( - TranscodingJob job, - StreamState state, - TimeSpan? transcodingPosition, - float? framerate, - double? percentComplete, - long? bytesTranscoded, - int? bitRate) - { - var ticks = transcodingPosition?.Ticks; - - if (job is not null) - { - job.Framerate = framerate; - job.CompletionPercentage = percentComplete; - job.TranscodingPositionTicks = ticks; - job.BytesTranscoded = bytesTranscoded; - job.BitRate = bitRate; - } - - var deviceId = state.Request.DeviceId; - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - var audioCodec = state.ActualOutputAudioCodec; - var videoCodec = state.ActualOutputVideoCodec; - var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType; - HardwareEncodingType? hardwareAccelerationType = null; - if (Enum.TryParse(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType)) - { - hardwareAccelerationType = parsedHardwareAccelerationType; - } - - _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo - { - Bitrate = bitRate ?? state.TotalOutputBitrate, - AudioCodec = audioCodec, - VideoCodec = videoCodec, - Container = state.OutputContainer, - Framerate = framerate, - CompletionPercentage = percentComplete, - Width = state.OutputWidth, - Height = state.OutputHeight, - AudioChannels = state.OutputAudioChannels, - IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec), - IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec), - HardwareAccelerationType = hardwareAccelerationType, - TranscodeReasons = state.TranscodeReasons - }); - } - } - - /// - /// Starts FFmpeg. - /// - /// The state. - /// The output path. - /// The command line arguments for FFmpeg. - /// The . - /// The . - /// The cancellation token source. - /// The working directory. - /// Task. - public async Task StartFfMpeg( - StreamState state, - string outputPath, - string commandLineArguments, - HttpRequest request, - TranscodingJobType transcodingJobType, - CancellationTokenSource cancellationTokenSource, - string? workingDirectory = null) - { - var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); - Directory.CreateDirectory(directory); - - await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); - - if (state.VideoRequest is not null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - var userId = request.HttpContext.User.GetUserId(); - var user = userId.Equals(default) ? null : _userManager.GetUserById(userId); - if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) - { - this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); - - throw new ArgumentException("User does not have access to video transcoding."); - } - } - - ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath); - - // If subtitles get burned in fonts may need to be extracted from the media file - 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) - { - await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); - } - - if (state.SubtitleStream.IsExternal && Path.GetExtension(state.SubtitleStream.Path.AsSpan()).Equals(".mks", StringComparison.OrdinalIgnoreCase)) - { - string subtitlePath = state.SubtitleStream.Path; - string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)); - string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture); - - await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); - } - } - - var process = new Process - { - StartInfo = new ProcessStartInfo - { - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, - UseShellExecute = false, - - // Must consume both stdout and stderr or deadlocks may occur - // RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - FileName = _mediaEncoder.EncoderPath, - Arguments = commandLineArguments, - WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory, - ErrorDialog = false - }, - EnableRaisingEvents = true - }; - - var transcodingJob = this.OnTranscodeBeginning( - outputPath, - state.Request.PlaySessionId, - state.MediaSource.LiveStreamId, - Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), - transcodingJobType, - process, - state.Request.DeviceId, - state, - cancellationTokenSource); - - _logger.LogInformation("{Filename} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); - - var logFilePrefix = "FFmpeg.Transcode-"; - if (state.VideoRequest is not null - && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) - ? "FFmpeg.Remux-" - : "FFmpeg.DirectStream-"; - } - - var logFilePath = Path.Combine( - _serverConfigurationManager.ApplicationPaths.LogDirectoryPath, - $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); - - // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. - Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - - var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; - var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); - await logStream.WriteAsync(commandLineLogMessageBytes, cancellationTokenSource.Token).ConfigureAwait(false); - - process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state); - - try - { - process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting FFmpeg"); - - this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); - - throw; - } - - _logger.LogDebug("Launched FFmpeg process"); - state.TranscodingJob = transcodingJob; - - // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback - _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError, logStream); - - // Wait for the file to exist before proceeding - var ffmpegTargetFile = state.WaitForPath ?? outputPath; - _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); - while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) - { - await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false); - } - - _logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile); - - if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited) - { - await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false); - - if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited) - { - await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false); - } - } - - if (!transcodingJob.HasExited) - { - StartThrottler(state, transcodingJob); - } - else if (transcodingJob.ExitCode != 0) - { - throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "FFmpeg exited with code {0}", transcodingJob.ExitCode)); - } - - _logger.LogDebug("StartFfMpeg() finished successfully"); - - return transcodingJob; - } - - private void StartThrottler(StreamState state, TranscodingJob transcodingJob) - { - if (EnableThrottling(state)) - { - transcodingJob.TranscodingThrottler = new TranscodingThrottler(transcodingJob, _loggerFactory.CreateLogger(), _serverConfigurationManager, _fileSystem, _mediaEncoder); - transcodingJob.TranscodingThrottler.Start(); - } - } - - private bool EnableThrottling(StreamState state) - { - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - - return state.InputProtocol == MediaProtocol.File && - state.RunTimeTicks.HasValue && - state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks && - state.IsInputVideo && - state.VideoType == VideoType.VideoFile; - } - - /// - /// Called when [transcode beginning]. - /// - /// The path. - /// The play session identifier. - /// The live stream identifier. - /// The transcoding job identifier. - /// The type. - /// The process. - /// The device id. - /// The state. - /// The cancellation token source. - /// TranscodingJob. - public TranscodingJob OnTranscodeBeginning( - string path, - string? playSessionId, - string? liveStreamId, - string transcodingJobId, - TranscodingJobType type, - Process process, - string? deviceId, - StreamState state, - CancellationTokenSource cancellationTokenSource) - { - lock (_activeTranscodingJobs) - { - var job = new TranscodingJob(_loggerFactory.CreateLogger()) - { - Type = type, - Path = path, - Process = process, - ActiveRequestCount = 1, - DeviceId = deviceId, - CancellationTokenSource = cancellationTokenSource, - Id = transcodingJobId, - PlaySessionId = playSessionId, - LiveStreamId = liveStreamId, - MediaSource = state.MediaSource - }; - - _activeTranscodingJobs.Add(job); - - ReportTranscodingProgress(job, state, null, null, null, null, null); - - return job; - } - } - - /// - /// Called when [transcode end]. - /// - /// The transcode job. - public void OnTranscodeEndRequest(TranscodingJob job) - { - job.ActiveRequestCount--; - _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount); - if (job.ActiveRequestCount <= 0) - { - PingTimer(job, false); - } - } - - /// - /// - /// The progressive - /// - /// Called when [transcode failed to start]. - /// - /// The path. - /// The type. - /// The state. - public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state) - { - lock (_activeTranscodingJobs) - { - var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - - if (job is not null) - { - _activeTranscodingJobs.Remove(job); - } - } - - lock (_transcodingLocks) - { - _transcodingLocks.Remove(path); - } - - if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) - { - _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); - } - } - - /// - /// Processes the exited. - /// - /// The process. - /// The job. - /// The state. - private void OnFfMpegProcessExited(Process process, TranscodingJob job, StreamState state) - { - job.HasExited = true; - job.ExitCode = process.ExitCode; - - ReportTranscodingProgress(job, state, null, null, null, null, null); - - _logger.LogDebug("Disposing stream resources"); - state.Dispose(); - - if (process.ExitCode == 0) - { - _logger.LogInformation("FFmpeg exited with code 0"); - } - else - { - _logger.LogError("FFmpeg exited with code {0}", process.ExitCode); - } - - job.Dispose(); - } - - private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) - { - if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) - { - var liveStreamResponse = await _mediaSourceManager.OpenLiveStream( - new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, - cancellationTokenSource.Token) - .ConfigureAwait(false); - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - - _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl); - - if (state.VideoRequest is not null) - { - _encodingHelper.TryStreamCopy(state); - } - } - - if (state.MediaSource.BufferMs.HasValue) - { - await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false); - } - } - - /// - /// Called when [transcode begin request]. - /// - /// The path. - /// The type. - /// The . - public TranscodingJob? OnTranscodeBeginRequest(string path, TranscodingJobType type) - { - lock (_activeTranscodingJobs) - { - var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - - if (job is null) - { - return null; - } - - OnTranscodeBeginRequest(job); - - return job; - } - } - - private void OnTranscodeBeginRequest(TranscodingJob job) - { - job.ActiveRequestCount++; - - if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive) - { - job.StopKillTimer(); - } - } - - /// - /// Gets the transcoding lock. - /// - /// The output path of the transcoded file. - /// A . - public SemaphoreSlim GetTranscodingLock(string outputPath) - { - lock (_transcodingLocks) - { - if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result)) - { - result = new SemaphoreSlim(1, 1); - _transcodingLocks[outputPath] = result; - } - - return result; - } - } - - private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e) - { - if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) - { - PingTranscodingJob(e.PlaySessionId, e.IsPaused); - } - } - - /// - /// Deletes the encoded media cache. - /// - private void DeleteEncodedMediaCache() - { - var path = _serverConfigurationManager.GetTranscodePath(); - if (!Directory.Exists(path)) - { - return; - } - - foreach (var file in _fileSystem.GetFilePaths(path, true)) - { - _fileSystem.DeleteFile(file); - } - } - - /// - /// Dispose transcoding job helper. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Dispose throttler. - /// - /// Disposing. - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _loggerFactory.Dispose(); - _sessionManager.PlaybackProgress -= OnPlaybackProgress; - _sessionManager.PlaybackStart -= OnPlaybackProgress; - } - } -} diff --git a/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs index 4f1abb1ffb..bd176bb6a5 100644 --- a/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs +++ b/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs @@ -1,4 +1,6 @@ -namespace Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Controller.Streaming; + +namespace Jellyfin.Api.Models.StreamingDtos; /// /// The hls video request dto. diff --git a/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs index 1cd3d01323..53b6d7575b 100644 --- a/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs +++ b/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs @@ -1,4 +1,6 @@ -namespace Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Controller.Streaming; + +namespace Jellyfin.Api.Models.StreamingDtos; /// /// The hls video request dto. diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs deleted file mode 100644 index 439f8052c1..0000000000 --- a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using Jellyfin.Api.Helpers; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.Dlna; - -namespace Jellyfin.Api.Models.StreamingDtos; - -/// -/// The stream state dto. -/// -public class StreamState : EncodingJobInfo, IDisposable -{ - private readonly IMediaSourceManager _mediaSourceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// The . - /// The singleton. - public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType, TranscodingJobHelper transcodingJobHelper) - : base(transcodingType) - { - _mediaSourceManager = mediaSourceManager; - _transcodingJobHelper = transcodingJobHelper; - } - - /// - /// Gets or sets the requested url. - /// - public string? RequestedUrl { get; set; } - - /// - /// Gets or sets the request. - /// - public StreamingRequestDto Request - { - get => (StreamingRequestDto)BaseRequest; - set - { - BaseRequest = value; - IsVideoRequest = VideoRequest is not null; - } - } - - /// - /// Gets the video request. - /// - public VideoRequestDto? VideoRequest => Request as VideoRequestDto; - - /// - /// Gets or sets the direct stream provicer. - /// - /// - /// Deprecated. - /// - public IDirectStreamProvider? DirectStreamProvider { get; set; } - - /// - /// Gets or sets the path to wait for. - /// - public string? WaitForPath { get; set; } - - /// - /// Gets a value indicating whether the request outputs video. - /// - public bool IsOutputVideo => Request is VideoRequestDto; - - /// - /// Gets the segment length. - /// - public int SegmentLength - { - get - { - if (Request.SegmentLength.HasValue) - { - return Request.SegmentLength.Value; - } - - if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) - { - var userAgent = UserAgent ?? string.Empty; - - if (userAgent.Contains("AppleTV", StringComparison.OrdinalIgnoreCase) - || userAgent.Contains("cfnetwork", StringComparison.OrdinalIgnoreCase) - || userAgent.Contains("ipad", StringComparison.OrdinalIgnoreCase) - || userAgent.Contains("iphone", StringComparison.OrdinalIgnoreCase) - || userAgent.Contains("ipod", StringComparison.OrdinalIgnoreCase)) - { - return 6; - } - - if (IsSegmentedLiveStream) - { - return 3; - } - - return 6; - } - - return 3; - } - } - - /// - /// Gets the minimum number of segments. - /// - public int MinSegments - { - get - { - if (Request.MinSegments.HasValue) - { - return Request.MinSegments.Value; - } - - return SegmentLength >= 10 ? 2 : 3; - } - } - - /// - /// Gets or sets the user agent. - /// - public string? UserAgent { get; set; } - - /// - /// Gets or sets a value indicating whether to estimate the content length. - /// - public bool EstimateContentLength { get; set; } - - /// - /// Gets or sets the transcode seek info. - /// - public TranscodeSeekInfo TranscodeSeekInfo { get; set; } - - /// - /// Gets or sets the transcoding job. - /// - public TranscodingJob? TranscodingJob { get; set; } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) - { - _transcodingJobHelper.ReportTranscodingProgress(TranscodingJob!, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); - } - - /// - /// Disposes the stream state. - /// - /// Whether the object is currently being disposed. - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - // REVIEW: Is this the right place for this? - if (MediaSource.RequiresClosing - && string.IsNullOrWhiteSpace(Request.LiveStreamId) - && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) - { - _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult(); - } - } - - TranscodingJob = null; - - _disposed = true; - } -} diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs deleted file mode 100644 index a357498d4c..0000000000 --- a/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs +++ /dev/null @@ -1,49 +0,0 @@ -using MediaBrowser.Controller.MediaEncoding; - -namespace Jellyfin.Api.Models.StreamingDtos; - -/// -/// The audio streaming request dto. -/// -public class StreamingRequestDto : BaseEncodingJobOptions -{ - /// - /// Gets or sets the params. - /// - public string? Params { get; set; } - - /// - /// Gets or sets the play session id. - /// - public string? PlaySessionId { get; set; } - - /// - /// Gets or sets the tag. - /// - public string? Tag { get; set; } - - /// - /// Gets or sets the segment container. - /// - public string? SegmentContainer { get; set; } - - /// - /// Gets or sets the segment length. - /// - public int? SegmentLength { get; set; } - - /// - /// Gets or sets the min segments. - /// - public int? MinSegments { get; set; } - - /// - /// Gets or sets the position of the requested segment in ticks. - /// - public long CurrentRuntimeTicks { get; set; } - - /// - /// Gets or sets the actual segment length in ticks. - /// - public long ActualSegmentLengthTicks { get; set; } -} diff --git a/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs deleted file mode 100644 index 8548fec1a1..0000000000 --- a/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Jellyfin.Api.Models.StreamingDtos; - -/// -/// The video request dto. -/// -public class VideoRequestDto : StreamingRequestDto -{ - /// - /// Gets a value indicating whether this instance has fixed resolution. - /// - /// true if this instance has fixed resolution; otherwise, false. - public bool HasFixedResolution => Width.HasValue || Height.HasValue; - - /// - /// Gets or sets a value indicating whether to enable subtitles in the manifest. - /// - public bool EnableSubtitlesInManifest { get; set; } - - /// - /// Gets or sets a value indicating whether to enable trickplay images. - /// - public bool EnableTrickplay { get; set; } -} diff --git a/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs b/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs new file mode 100644 index 0000000000..c19a12ae7a --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs @@ -0,0 +1,104 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Streaming; + +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// A service for managing media transcoding. +/// +public interface ITranscodeManager +{ + /// + /// Get transcoding job. + /// + /// Playback session id. + /// The transcoding job. + public TranscodingJob? GetTranscodingJob(string playSessionId); + + /// + /// Get transcoding job. + /// + /// Path to the transcoding file. + /// The . + /// The transcoding job. + public TranscodingJob? GetTranscodingJob(string path, TranscodingJobType type); + + /// + /// Ping transcoding job. + /// + /// Play session id. + /// Is user paused. + /// Play session id is null. + public void PingTranscodingJob(string playSessionId, bool? isUserPaused); + + /// + /// Kills the single transcoding job. + /// + /// The device id. + /// The play session identifier. + /// The delete files. + /// Task. + public Task KillTranscodingJobs(string deviceId, string? playSessionId, Func deleteFiles); + + /// + /// Report the transcoding progress to the session manager. + /// + /// The of which the progress will be reported. + /// The of the current transcoding job. + /// The current transcoding position. + /// The framerate of the transcoding job. + /// The completion percentage of the transcode. + /// The number of bytes transcoded. + /// The bitrate of the transcoding job. + public void ReportTranscodingProgress( + TranscodingJob job, + StreamState state, + TimeSpan? transcodingPosition, + float? framerate, + double? percentComplete, + long? bytesTranscoded, + int? bitRate); + + /// + /// Starts FFMpeg. + /// + /// The state. + /// The output path. + /// The command line arguments for FFmpeg. + /// The user id. + /// The . + /// The cancellation token source. + /// The working directory. + /// Task. + public Task StartFfMpeg( + StreamState state, + string outputPath, + string commandLineArguments, + Guid userId, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource, + string? workingDirectory = null); + + /// + /// Called when [transcode begin request]. + /// + /// The path. + /// The type. + /// The . + public TranscodingJob? OnTranscodeBeginRequest(string path, TranscodingJobType type); + + /// + /// Called when [transcode end]. + /// + /// The transcode job. + public void OnTranscodeEndRequest(TranscodingJob job); + + /// + /// Gets the transcoding lock. + /// + /// The output path of the transcoded file. + /// A . + public SemaphoreSlim GetTranscodingLock(string outputPath); +} diff --git a/MediaBrowser.Controller/Streaming/StreamState.cs b/MediaBrowser.Controller/Streaming/StreamState.cs new file mode 100644 index 0000000000..b5dbe29ec7 --- /dev/null +++ b/MediaBrowser.Controller/Streaming/StreamState.cs @@ -0,0 +1,183 @@ +using System; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; + +namespace MediaBrowser.Controller.Streaming; + +/// +/// The stream state dto. +/// +public class StreamState : EncodingJobInfo, IDisposable +{ + private readonly IMediaSourceManager _mediaSourceManager; + private readonly ITranscodeManager _transcodeManager; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// The . + /// The singleton. + public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType, ITranscodeManager transcodeManager) + : base(transcodingType) + { + _mediaSourceManager = mediaSourceManager; + _transcodeManager = transcodeManager; + } + + /// + /// Gets or sets the requested url. + /// + public string? RequestedUrl { get; set; } + + /// + /// Gets or sets the request. + /// + public StreamingRequestDto Request + { + get => (StreamingRequestDto)BaseRequest; + set + { + BaseRequest = value; + IsVideoRequest = VideoRequest is not null; + } + } + + /// + /// Gets the video request. + /// + public VideoRequestDto? VideoRequest => Request as VideoRequestDto; + + /// + /// Gets or sets the direct stream provicer. + /// + /// + /// Deprecated. + /// + public IDirectStreamProvider? DirectStreamProvider { get; set; } + + /// + /// Gets or sets the path to wait for. + /// + public string? WaitForPath { get; set; } + + /// + /// Gets a value indicating whether the request outputs video. + /// + public bool IsOutputVideo => Request is VideoRequestDto; + + /// + /// Gets the segment length. + /// + public int SegmentLength + { + get + { + if (Request.SegmentLength.HasValue) + { + return Request.SegmentLength.Value; + } + + if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) + { + var userAgent = UserAgent ?? string.Empty; + + if (userAgent.Contains("AppleTV", StringComparison.OrdinalIgnoreCase) + || userAgent.Contains("cfnetwork", StringComparison.OrdinalIgnoreCase) + || userAgent.Contains("ipad", StringComparison.OrdinalIgnoreCase) + || userAgent.Contains("iphone", StringComparison.OrdinalIgnoreCase) + || userAgent.Contains("ipod", StringComparison.OrdinalIgnoreCase)) + { + return 6; + } + + if (IsSegmentedLiveStream) + { + return 3; + } + + return 6; + } + + return 3; + } + } + + /// + /// Gets the minimum number of segments. + /// + public int MinSegments + { + get + { + if (Request.MinSegments.HasValue) + { + return Request.MinSegments.Value; + } + + return SegmentLength >= 10 ? 2 : 3; + } + } + + /// + /// Gets or sets the user agent. + /// + public string? UserAgent { get; set; } + + /// + /// Gets or sets a value indicating whether to estimate the content length. + /// + public bool EstimateContentLength { get; set; } + + /// + /// Gets or sets the transcode seek info. + /// + public TranscodeSeekInfo TranscodeSeekInfo { get; set; } + + /// + /// Gets or sets the transcoding job. + /// + public TranscodingJob? TranscodingJob { get; set; } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + { + _transcodeManager.ReportTranscodingProgress(TranscodingJob!, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); + } + + /// + /// Disposes the stream state. + /// + /// Whether the object is currently being disposed. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + // REVIEW: Is this the right place for this? + if (MediaSource.RequiresClosing + && string.IsNullOrWhiteSpace(Request.LiveStreamId) + && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) + { + _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult(); + } + } + + TranscodingJob = null; + + _disposed = true; + } +} diff --git a/MediaBrowser.Controller/Streaming/StreamingRequestDto.cs b/MediaBrowser.Controller/Streaming/StreamingRequestDto.cs new file mode 100644 index 0000000000..e47ef65f06 --- /dev/null +++ b/MediaBrowser.Controller/Streaming/StreamingRequestDto.cs @@ -0,0 +1,49 @@ +using MediaBrowser.Controller.MediaEncoding; + +namespace MediaBrowser.Controller.Streaming; + +/// +/// The audio streaming request dto. +/// +public class StreamingRequestDto : BaseEncodingJobOptions +{ + /// + /// Gets or sets the params. + /// + public string? Params { get; set; } + + /// + /// Gets or sets the play session id. + /// + public string? PlaySessionId { get; set; } + + /// + /// Gets or sets the tag. + /// + public string? Tag { get; set; } + + /// + /// Gets or sets the segment container. + /// + public string? SegmentContainer { get; set; } + + /// + /// Gets or sets the segment length. + /// + public int? SegmentLength { get; set; } + + /// + /// Gets or sets the min segments. + /// + public int? MinSegments { get; set; } + + /// + /// Gets or sets the position of the requested segment in ticks. + /// + public long CurrentRuntimeTicks { get; set; } + + /// + /// Gets or sets the actual segment length in ticks. + /// + public long ActualSegmentLengthTicks { get; set; } +} diff --git a/MediaBrowser.Controller/Streaming/VideoRequestDto.cs b/MediaBrowser.Controller/Streaming/VideoRequestDto.cs new file mode 100644 index 0000000000..44dc831fdc --- /dev/null +++ b/MediaBrowser.Controller/Streaming/VideoRequestDto.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Controller.Streaming; + +/// +/// The video request dto. +/// +public class VideoRequestDto : StreamingRequestDto +{ + /// + /// Gets a value indicating whether this instance has fixed resolution. + /// + /// true if this instance has fixed resolution; otherwise, false. + public bool HasFixedResolution => Width.HasValue || Height.HasValue; + + /// + /// Gets or sets a value indicating whether to enable subtitles in the manifest. + /// + public bool EnableSubtitlesInManifest { get; set; } + + /// + /// Gets or sets a value indicating whether to enable trickplay images. + /// + public bool EnableTrickplay { get; set; } +} diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs new file mode 100644 index 0000000000..483d0a1d82 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -0,0 +1,750 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Common; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.Streaming; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.MediaEncoding.Transcoding; + +/// +public sealed class TranscodeManager : ITranscodeManager, IDisposable +{ + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly IFileSystem _fileSystem; + private readonly IApplicationPaths _appPaths; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IUserManager _userManager; + private readonly ISessionManager _sessionManager; + private readonly EncodingHelper _encodingHelper; + private readonly IMediaEncoder _mediaEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IAttachmentExtractor _attachmentExtractor; + + private readonly List _activeTranscodingJobs = new(); + private readonly Dictionary _transcodingLocks = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + /// The . + public TranscodeManager( + ILoggerFactory loggerFactory, + IFileSystem fileSystem, + IApplicationPaths appPaths, + IServerConfigurationManager serverConfigurationManager, + IUserManager userManager, + ISessionManager sessionManager, + EncodingHelper encodingHelper, + IMediaEncoder mediaEncoder, + IMediaSourceManager mediaSourceManager, + IAttachmentExtractor attachmentExtractor) + { + _loggerFactory = loggerFactory; + _fileSystem = fileSystem; + _appPaths = appPaths; + _serverConfigurationManager = serverConfigurationManager; + _userManager = userManager; + _sessionManager = sessionManager; + _encodingHelper = encodingHelper; + _mediaEncoder = mediaEncoder; + _mediaSourceManager = mediaSourceManager; + _attachmentExtractor = attachmentExtractor; + + _logger = loggerFactory.CreateLogger(); + DeleteEncodedMediaCache(); + _sessionManager.PlaybackProgress += OnPlaybackProgress; + _sessionManager.PlaybackStart += OnPlaybackProgress; + } + + /// + public TranscodingJob? GetTranscodingJob(string playSessionId) + { + lock (_activeTranscodingJobs) + { + return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase)); + } + } + + /// + public TranscodingJob? GetTranscodingJob(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) + { + return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + } + } + + /// + public void PingTranscodingJob(string playSessionId, bool? isUserPaused) + { + ArgumentException.ThrowIfNullOrEmpty(playSessionId); + + _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused); + + List jobs; + + lock (_activeTranscodingJobs) + { + // This is really only needed for HLS. + // Progressive streams can stop on their own reliably. + jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + foreach (var job in jobs) + { + if (isUserPaused.HasValue) + { + _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id); + job.IsUserPaused = isUserPaused.Value; + } + + PingTimer(job, true); + } + } + + private void PingTimer(TranscodingJob job, bool isProgressCheckIn) + { + if (job.HasExited) + { + job.StopKillTimer(); + return; + } + + var timerDuration = 10000; + + if (job.Type != TranscodingJobType.Progressive) + { + timerDuration = 60000; + } + + job.PingTimeout = timerDuration; + job.LastPingDate = DateTime.UtcNow; + + // Don't start the timer for playback checkins with progressive streaming + if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn) + { + job.StartKillTimer(OnTranscodeKillTimerStopped); + } + else + { + job.ChangeKillTimerIfStarted(); + } + } + + private async void OnTranscodeKillTimerStopped(object? state) + { + var job = state as TranscodingJob ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJob)}", nameof(state)); + if (!job.HasExited && job.Type != TranscodingJobType.Progressive) + { + var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; + + if (timeSinceLastPing < job.PingTimeout) + { + job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout); + return; + } + } + + _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); + + await KillTranscodingJob(job, true, path => true).ConfigureAwait(false); + } + + /// + public Task KillTranscodingJobs(string deviceId, string? playSessionId, Func deleteFiles) + { + var jobs = new List(); + + lock (_activeTranscodingJobs) + { + // This is really only needed for HLS. + // Progressive streams can stop on their own reliably. + jobs.AddRange(_activeTranscodingJobs.Where(j => string.IsNullOrWhiteSpace(playSessionId) + ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase) + : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase))); + } + + return Task.WhenAll(GetKillJobs()); + + IEnumerable GetKillJobs() + { + foreach (var job in jobs) + { + yield return KillTranscodingJob(job, false, deleteFiles); + } + } + } + + private async Task KillTranscodingJob(TranscodingJob job, bool closeLiveStream, Func delete) + { + job.DisposeKillTimer(); + + _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); + + lock (_activeTranscodingJobs) + { + _activeTranscodingJobs.Remove(job); + + if (job.CancellationTokenSource?.IsCancellationRequested == false) + { +#pragma warning disable CA1849 // Can't await in lock block + job.CancellationTokenSource.Cancel(); +#pragma warning restore CA1849 + } + } + + lock (_transcodingLocks) + { + _transcodingLocks.Remove(job.Path!); + } + + job.Stop(); + + if (delete(job.Path!)) + { + await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false); + if (job.MediaSource?.VideoType == VideoType.Dvd || job.MediaSource?.VideoType == VideoType.BluRay) + { + var concatFilePath = Path.Join(_serverConfigurationManager.GetTranscodePath(), job.MediaSource.Id + ".concat"); + if (File.Exists(concatFilePath)) + { + _logger.LogInformation("Deleting ffmpeg concat configuration at {Path}", concatFilePath); + File.Delete(concatFilePath); + } + } + } + + if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) + { + try + { + await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing live stream for {Path}", job.Path); + } + } + } + + private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs) + { + if (retryCount >= 10) + { + return; + } + + _logger.LogInformation("Deleting partial stream file(s) {Path}", path); + + await Task.Delay(delayMs).ConfigureAwait(false); + + try + { + if (jobType == TranscodingJobType.Progressive) + { + DeleteProgressivePartialStreamFiles(path); + } + else + { + DeleteHlsPartialStreamFiles(path); + } + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); + + await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); + } + } + + private void DeleteProgressivePartialStreamFiles(string outputFilePath) + { + if (File.Exists(outputFilePath)) + { + _fileSystem.DeleteFile(outputFilePath); + } + } + + private void DeleteHlsPartialStreamFiles(string outputFilePath) + { + var directory = Path.GetDirectoryName(outputFilePath) + ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath)); + + var name = Path.GetFileNameWithoutExtension(outputFilePath); + + var filesToDelete = _fileSystem.GetFilePaths(directory) + .Where(f => f.Contains(name, StringComparison.OrdinalIgnoreCase)); + + List? exs = null; + foreach (var file in filesToDelete) + { + try + { + _logger.LogDebug("Deleting HLS file {0}", file); + _fileSystem.DeleteFile(file); + } + catch (IOException ex) + { + (exs ??= new List(4)).Add(ex); + _logger.LogError(ex, "Error deleting HLS file {Path}", file); + } + } + + if (exs is not null) + { + throw new AggregateException("Error deleting HLS files", exs); + } + } + + /// + public void ReportTranscodingProgress( + TranscodingJob job, + StreamState state, + TimeSpan? transcodingPosition, + float? framerate, + double? percentComplete, + long? bytesTranscoded, + int? bitRate) + { + var ticks = transcodingPosition?.Ticks; + + if (job is not null) + { + job.Framerate = framerate; + job.CompletionPercentage = percentComplete; + job.TranscodingPositionTicks = ticks; + job.BytesTranscoded = bytesTranscoded; + job.BitRate = bitRate; + } + + var deviceId = state.Request.DeviceId; + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + var audioCodec = state.ActualOutputAudioCodec; + var videoCodec = state.ActualOutputVideoCodec; + var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType; + HardwareEncodingType? hardwareAccelerationType = null; + if (Enum.TryParse(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType)) + { + hardwareAccelerationType = parsedHardwareAccelerationType; + } + + _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo + { + Bitrate = bitRate ?? state.TotalOutputBitrate, + AudioCodec = audioCodec, + VideoCodec = videoCodec, + Container = state.OutputContainer, + Framerate = framerate, + CompletionPercentage = percentComplete, + Width = state.OutputWidth, + Height = state.OutputHeight, + AudioChannels = state.OutputAudioChannels, + IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec), + IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec), + HardwareAccelerationType = hardwareAccelerationType, + TranscodeReasons = state.TranscodeReasons + }); + } + } + + /// + public async Task StartFfMpeg( + StreamState state, + string outputPath, + string commandLineArguments, + Guid userId, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource, + string? workingDirectory = null) + { + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(directory); + + await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); + + if (state.VideoRequest is not null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + var user = userId.Equals(default) ? null : _userManager.GetUserById(userId); + if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) + { + this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); + + throw new ArgumentException("User does not have access to video transcoding."); + } + } + + ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath); + + // If subtitles get burned in fonts may need to be extracted from the media file + 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) + { + await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); + } + + if (state.SubtitleStream.IsExternal && Path.GetExtension(state.SubtitleStream.Path.AsSpan()).Equals(".mks", StringComparison.OrdinalIgnoreCase)) + { + string subtitlePath = state.SubtitleStream.Path; + string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)); + string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture); + + await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + UseShellExecute = false, + + // Must consume both stdout and stderr or deadlocks may occur + // RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + FileName = _mediaEncoder.EncoderPath, + Arguments = commandLineArguments, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory, + ErrorDialog = false + }, + EnableRaisingEvents = true + }; + + var transcodingJob = this.OnTranscodeBeginning( + outputPath, + state.Request.PlaySessionId, + state.MediaSource.LiveStreamId, + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), + transcodingJobType, + process, + state.Request.DeviceId, + state, + cancellationTokenSource); + + _logger.LogInformation("{Filename} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + + var logFilePrefix = "FFmpeg.Transcode-"; + if (state.VideoRequest is not null + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) + ? "FFmpeg.Remux-" + : "FFmpeg.DirectStream-"; + } + + var logFilePath = Path.Combine( + _serverConfigurationManager.ApplicationPaths.LogDirectoryPath, + $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); + + // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + Stream logStream = new FileStream( + logFilePath, + FileMode.Create, + FileAccess.Write, + FileShare.Read, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous); + + var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; + var commandLineLogMessageBytes = Encoding.UTF8.GetBytes( + JsonSerializer.Serialize(state.MediaSource) + + Environment.NewLine + + Environment.NewLine + + commandLineLogMessage + + Environment.NewLine + + Environment.NewLine); + + await logStream.WriteAsync(commandLineLogMessageBytes, cancellationTokenSource.Token).ConfigureAwait(false); + + process.Exited += (_, _) => OnFfMpegProcessExited(process, transcodingJob, state); + + try + { + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting FFmpeg"); + this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); + + throw; + } + + _logger.LogDebug("Launched FFmpeg process"); + state.TranscodingJob = transcodingJob; + + // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback + _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError, logStream); + + // Wait for the file to exist before proceeding + var ffmpegTargetFile = state.WaitForPath ?? outputPath; + _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); + while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) + { + await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false); + } + + _logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile); + + if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited) + { + await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false); + + if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited) + { + await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + + if (!transcodingJob.HasExited) + { + StartThrottler(state, transcodingJob); + } + else if (transcodingJob.ExitCode != 0) + { + throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "FFmpeg exited with code {0}", transcodingJob.ExitCode)); + } + + _logger.LogDebug("StartFfMpeg() finished successfully"); + + return transcodingJob; + } + + private void StartThrottler(StreamState state, TranscodingJob transcodingJob) + { + if (EnableThrottling(state)) + { + transcodingJob.TranscodingThrottler = new TranscodingThrottler(transcodingJob, _loggerFactory.CreateLogger(), _serverConfigurationManager, _fileSystem, _mediaEncoder); + transcodingJob.TranscodingThrottler.Start(); + } + } + + private static bool EnableThrottling(StreamState state) + => state.InputProtocol == MediaProtocol.File + && state.RunTimeTicks.HasValue + && state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks + && state.IsInputVideo + && state.VideoType == VideoType.VideoFile; + + private TranscodingJob OnTranscodeBeginning( + string path, + string? playSessionId, + string? liveStreamId, + string transcodingJobId, + TranscodingJobType type, + Process process, + string? deviceId, + StreamState state, + CancellationTokenSource cancellationTokenSource) + { + lock (_activeTranscodingJobs) + { + var job = new TranscodingJob(_loggerFactory.CreateLogger()) + { + Type = type, + Path = path, + Process = process, + ActiveRequestCount = 1, + DeviceId = deviceId, + CancellationTokenSource = cancellationTokenSource, + Id = transcodingJobId, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + MediaSource = state.MediaSource + }; + + _activeTranscodingJobs.Add(job); + + ReportTranscodingProgress(job, state, null, null, null, null, null); + + return job; + } + } + + /// + public void OnTranscodeEndRequest(TranscodingJob job) + { + job.ActiveRequestCount--; + _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount); + if (job.ActiveRequestCount <= 0) + { + PingTimer(job, false); + } + } + + private void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state) + { + lock (_activeTranscodingJobs) + { + var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + + if (job is not null) + { + _activeTranscodingJobs.Remove(job); + } + } + + lock (_transcodingLocks) + { + _transcodingLocks.Remove(path); + } + + if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) + { + _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); + } + } + + private void OnFfMpegProcessExited(Process process, TranscodingJob job, StreamState state) + { + job.HasExited = true; + job.ExitCode = process.ExitCode; + + ReportTranscodingProgress(job, state, null, null, null, null, null); + + _logger.LogDebug("Disposing stream resources"); + state.Dispose(); + + if (process.ExitCode == 0) + { + _logger.LogInformation("FFmpeg exited with code 0"); + } + else + { + _logger.LogError("FFmpeg exited with code {0}", process.ExitCode); + } + + job.Dispose(); + } + + private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) + { + if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) + { + var liveStreamResponse = await _mediaSourceManager.OpenLiveStream( + new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, + cancellationTokenSource.Token) + .ConfigureAwait(false); + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + + _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl); + + if (state.VideoRequest is not null) + { + _encodingHelper.TryStreamCopy(state); + } + } + + if (state.MediaSource.BufferMs.HasValue) + { + await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + + /// + public TranscodingJob? OnTranscodeBeginRequest(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) + { + var job = _activeTranscodingJobs + .FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + + if (job is null) + { + return null; + } + + job.ActiveRequestCount++; + if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive) + { + job.StopKillTimer(); + } + + return job; + } + } + + /// + public SemaphoreSlim GetTranscodingLock(string outputPath) + { + lock (_transcodingLocks) + { + if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result)) + { + result = new SemaphoreSlim(1, 1); + _transcodingLocks[outputPath] = result; + } + + return result; + } + } + + private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e) + { + if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) + { + PingTranscodingJob(e.PlaySessionId, e.IsPaused); + } + } + + private void DeleteEncodedMediaCache() + { + var path = _serverConfigurationManager.GetTranscodePath(); + if (!Directory.Exists(path)) + { + return; + } + + foreach (var file in _fileSystem.GetFilePaths(path, true)) + { + _fileSystem.DeleteFile(file); + } + } + + /// + public void Dispose() + { + _sessionManager.PlaybackProgress -= OnPlaybackProgress; + _sessionManager.PlaybackStart -= OnPlaybackProgress; + } +} -- cgit v1.2.3 From c49539cbe0152d23d82d7710ff2bc32e5d3d187b Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 21 Dec 2023 12:20:30 -0500 Subject: Move ProgressiveFileStream to Controller --- .../LiveTv/EmbyTV/DirectRecorder.cs | 1 + Jellyfin.Api/Controllers/LiveTvController.cs | 1 + Jellyfin.Api/Helpers/ProgressiveFileStream.cs | 182 --------------------- .../Streaming/ProgressiveFileStream.cs | 182 +++++++++++++++++++++ 4 files changed, 184 insertions(+), 182 deletions(-) delete mode 100644 Jellyfin.Api/Helpers/ProgressiveFileStream.cs create mode 100644 MediaBrowser.Controller/Streaming/ProgressiveFileStream.cs diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs index ddf7b882a7..7df66d3585 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Helpers; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Streaming; using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index a40f273ae4..5502836239 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -25,6 +25,7 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; diff --git a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs deleted file mode 100644 index 98ea844a96..0000000000 --- a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.IO; - -namespace Jellyfin.Api.Helpers; - -/// -/// A progressive file stream for transferring transcoded files as they are written to. -/// -public class ProgressiveFileStream : Stream -{ - private readonly Stream _stream; - private readonly TranscodingJob? _job; - private readonly ITranscodeManager? _transcodeManager; - private readonly int _timeoutMs; - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - /// The path to the transcoded file. - /// The transcoding job information. - /// The transcode manager. - /// The timeout duration in milliseconds. - public ProgressiveFileStream(string filePath, TranscodingJob? job, ITranscodeManager transcodeManager, int timeoutMs = 30000) - { - _job = job; - _transcodeManager = transcodeManager; - _timeoutMs = timeoutMs; - - _stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); - } - - /// - /// Initializes a new instance of the class. - /// - /// The stream to progressively copy. - /// The timeout duration in milliseconds. - public ProgressiveFileStream(Stream stream, int timeoutMs = 30000) - { - _job = null; - _transcodeManager = null; - _timeoutMs = timeoutMs; - _stream = stream; - } - - /// - public override bool CanRead => _stream.CanRead; - - /// - public override bool CanSeek => false; - - /// - public override bool CanWrite => false; - - /// - public override long Length => throw new NotSupportedException(); - - /// - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - /// - public override void Flush() - { - // Not supported - } - - /// - public override int Read(byte[] buffer, int offset, int count) - => Read(buffer.AsSpan(offset, count)); - - /// - public override int Read(Span buffer) - { - int totalBytesRead = 0; - var stopwatch = Stopwatch.StartNew(); - - while (true) - { - totalBytesRead += _stream.Read(buffer); - if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) - { - break; - } - - Thread.Sleep(50); - } - - UpdateBytesWritten(totalBytesRead); - - return totalBytesRead; - } - - /// - public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); - - /// - public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) - { - int totalBytesRead = 0; - var stopwatch = Stopwatch.StartNew(); - - while (true) - { - totalBytesRead += await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) - { - break; - } - - await Task.Delay(50, cancellationToken).ConfigureAwait(false); - } - - UpdateBytesWritten(totalBytesRead); - - return totalBytesRead; - } - - /// - public override long Seek(long offset, SeekOrigin origin) - => throw new NotSupportedException(); - - /// - public override void SetLength(long value) - => throw new NotSupportedException(); - - /// - public override void Write(byte[] buffer, int offset, int count) - => throw new NotSupportedException(); - - /// - protected override void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - try - { - if (disposing) - { - _stream.Dispose(); - - if (_job is not null) - { - _transcodeManager?.OnTranscodeEndRequest(_job); - } - } - } - finally - { - _disposed = true; - base.Dispose(disposing); - } - } - - private void UpdateBytesWritten(int totalBytesRead) - { - if (_job is not null) - { - _job.BytesDownloaded += totalBytesRead; - } - } - - private bool StopReading(int bytesRead, long elapsed) - { - // It should stop reading when anything has been successfully read or if the job has exited - // If the job is null, however, it's a live stream and will require user action to close, - // but don't keep it open indefinitely if it isn't reading anything - return bytesRead > 0 || (_job?.HasExited ?? elapsed >= _timeoutMs); - } -} diff --git a/MediaBrowser.Controller/Streaming/ProgressiveFileStream.cs b/MediaBrowser.Controller/Streaming/ProgressiveFileStream.cs new file mode 100644 index 0000000000..f44dc92d71 --- /dev/null +++ b/MediaBrowser.Controller/Streaming/ProgressiveFileStream.cs @@ -0,0 +1,182 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.IO; + +namespace MediaBrowser.Controller.Streaming; + +/// +/// A progressive file stream for transferring transcoded files as they are written to. +/// +public class ProgressiveFileStream : Stream +{ + private readonly Stream _stream; + private readonly TranscodingJob? _job; + private readonly ITranscodeManager? _transcodeManager; + private readonly int _timeoutMs; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the transcoded file. + /// The transcoding job information. + /// The transcode manager. + /// The timeout duration in milliseconds. + public ProgressiveFileStream(string filePath, TranscodingJob? job, ITranscodeManager transcodeManager, int timeoutMs = 30000) + { + _job = job; + _transcodeManager = transcodeManager; + _timeoutMs = timeoutMs; + + _stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + } + + /// + /// Initializes a new instance of the class. + /// + /// The stream to progressively copy. + /// The timeout duration in milliseconds. + public ProgressiveFileStream(Stream stream, int timeoutMs = 30000) + { + _job = null; + _transcodeManager = null; + _timeoutMs = timeoutMs; + _stream = stream; + } + + /// + public override bool CanRead => _stream.CanRead; + + /// + public override bool CanSeek => false; + + /// + public override bool CanWrite => false; + + /// + public override long Length => throw new NotSupportedException(); + + /// + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + /// + public override void Flush() + { + // Not supported + } + + /// + public override int Read(byte[] buffer, int offset, int count) + => Read(buffer.AsSpan(offset, count)); + + /// + public override int Read(Span buffer) + { + int totalBytesRead = 0; + var stopwatch = Stopwatch.StartNew(); + + while (true) + { + totalBytesRead += _stream.Read(buffer); + if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) + { + break; + } + + Thread.Sleep(50); + } + + UpdateBytesWritten(totalBytesRead); + + return totalBytesRead; + } + + /// + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); + + /// + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + int totalBytesRead = 0; + var stopwatch = Stopwatch.StartNew(); + + while (true) + { + totalBytesRead += await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) + { + break; + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + + UpdateBytesWritten(totalBytesRead); + + return totalBytesRead; + } + + /// + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + /// + public override void SetLength(long value) + => throw new NotSupportedException(); + + /// + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + /// + protected override void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + try + { + if (disposing) + { + _stream.Dispose(); + + if (_job is not null) + { + _transcodeManager?.OnTranscodeEndRequest(_job); + } + } + } + finally + { + _disposed = true; + base.Dispose(disposing); + } + } + + private void UpdateBytesWritten(int totalBytesRead) + { + if (_job is not null) + { + _job.BytesDownloaded += totalBytesRead; + } + } + + private bool StopReading(int bytesRead, long elapsed) + { + // It should stop reading when anything has been successfully read or if the job has exited + // If the job is null, however, it's a live stream and will require user action to close, + // but don't keep it open indefinitely if it isn't reading anything + return bytesRead > 0 || (_job?.HasExited ?? elapsed >= _timeoutMs); + } +} -- cgit v1.2.3 From 0ca263a142a9635eef3075c5288acbf0c5fa75bd Mon Sep 17 00:00:00 2001 From: Peyman M Date: Thu, 21 Dec 2023 01:24:34 +0000 Subject: Translated using Weblate (Persian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fa/ --- Emby.Server.Implementations/Localization/Core/fa.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index 8e4bba25b6..8364ce2360 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -124,5 +124,7 @@ "TaskKeyframeExtractorDescription": "فریم های کلیدی را از فایل های ویدئویی استخراج می کند تا لیست های پخش HLS دقیق تری ایجاد کند. این کار ممکن است برای مدت طولانی اجرا شود.", "TaskKeyframeExtractor": "استخراج کننده فریم کلیدی", "External": "خارجی", - "HearingImpaired": "مشکل شنوایی" + "HearingImpaired": "مشکل شنوایی", + "TaskRefreshTrickplayImages": "تولید تصاویر Trickplay", + "TaskRefreshTrickplayImagesDescription": "تولید پیش‌نمایش های trickplay برای ویدیو های فعال شده در کتابخانه." } -- cgit v1.2.3 From 9fdbb71a50486d1bdaaf9170db3fc9bb577db1ea Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 22 Dec 2023 01:50:13 +0000 Subject: chore(deps): update github/codeql-action action to v3.22.12 --- .github/workflows/ci-codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 89f9c59d78..7c07335980 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@b374143c1149a9115d881581d29b8390bbcbb59c # v3.22.11 + uses: github/codeql-action/init@012739e5082ff0c22ca6d6ab32e07c36df03c4a4 # v3.22.12 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@b374143c1149a9115d881581d29b8390bbcbb59c # v3.22.11 + uses: github/codeql-action/autobuild@012739e5082ff0c22ca6d6ab32e07c36df03c4a4 # v3.22.12 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b374143c1149a9115d881581d29b8390bbcbb59c # v3.22.11 + uses: github/codeql-action/analyze@012739e5082ff0c22ca6d6ab32e07c36df03c4a4 # v3.22.12 -- cgit v1.2.3 From 836c2032fc6f023ac3d09adf2ed5dfacd37076c8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 23 Dec 2023 01:52:09 +0000 Subject: chore(deps): update xunit-dotnet monorepo --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0c234bcf8b..90280d98bd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,8 +83,8 @@ - + - + -- cgit v1.2.3 From c41ecba2cd864e97df50a4eda848fbd696823bb5 Mon Sep 17 00:00:00 2001 From: Stephen Mwangi Date: Sat, 23 Dec 2023 07:49:02 +0300 Subject: Fix Somalia's display name --- Emby.Server.Implementations/Localization/countries.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/countries.json b/Emby.Server.Implementations/Localization/countries.json index 22ffc5e097..0a11b3e458 100644 --- a/Emby.Server.Implementations/Localization/countries.json +++ b/Emby.Server.Implementations/Localization/countries.json @@ -696,7 +696,7 @@ "TwoLetterISORegionName": "SI" }, { - "DisplayName": "Soomaaliya", + "DisplayName": "Somalia", "Name": "SO", "ThreeLetterISORegionName": "SOM", "TwoLetterISORegionName": "SO" -- cgit v1.2.3 From a3a071154533206957e358adafb40f765fa946b1 Mon Sep 17 00:00:00 2001 From: Sergey Date: Sat, 23 Dec 2023 16:06:09 -0500 Subject: Added translation using Weblate (Armenian) --- Emby.Server.Implementations/Localization/Core/hy.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 Emby.Server.Implementations/Localization/Core/hy.json diff --git a/Emby.Server.Implementations/Localization/Core/hy.json b/Emby.Server.Implementations/Localization/Core/hy.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/hy.json @@ -0,0 +1 @@ +{} -- cgit v1.2.3 From 0c9b1f1ebfe6e14c0b9dc48d23f57c97bb144261 Mon Sep 17 00:00:00 2001 From: Sergey Date: Sat, 23 Dec 2023 21:19:09 +0000 Subject: Translated using Weblate (Armenian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/hy/ --- .../Localization/Core/hy.json | 36 +++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/hy.json b/Emby.Server.Implementations/Localization/Core/hy.json index 0967ef424b..c348836fe6 100644 --- a/Emby.Server.Implementations/Localization/Core/hy.json +++ b/Emby.Server.Implementations/Localization/Core/hy.json @@ -1 +1,35 @@ -{} +{ + "TasksLibraryCategory": "Գրադարան", + "TasksApplicationCategory": "Հավելված", + "TaskCleanActivityLog": "Մաքրել ակտիվության մատյանը", + "Application": "Հավելված", + "AuthenticationSucceededWithUserName": "{0} հաջողությամբ վավերականացվել են", + "Books": "Գրքեր", + "CameraImageUploadedFrom": "Նոր լուսանկար է վերբեռնվել {0}-ի կողմից", + "Channels": "Ալիքներ", + "DeviceOfflineWithName": "{0}ը անջատվեց", + "External": "Արտաքին", + "FailedLoginAttemptWithUserName": "Ձախողված մուտքի փործ {0}-ի կողմից", + "Folders": "Պանակներ", + "HeaderContinueWatching": "Շարունակել դիտումը", + "Inherit": "Ժառանգել", + "ItemAddedWithName": "{0}ը ավացված է գրադարանի մեջ", + "ItemRemovedWithName": "{0}ը հեռացված է գրադարանից", + "LabelIpAddressValue": "IP հասցե` {0}", + "Movies": "Ֆիլմեր", + "Music": "Երաժշտություն", + "NameSeasonNumber": "Սեզոն {0}", + "Photos": "Լուսանկարներ", + "PluginInstalledWithName": "{0}ն տեղադրված է", + "Songs": "Երգեր", + "System": "Համակարգ", + "TvShows": "Հեռուստասերիալներ", + "User": "Օգտատեր", + "VersionNumber": "Տարբերակ {0}", + "TasksMaintenanceCategory": "Սպասարկում", + "TasksChannelsCategory": "Ինտերնետային ալիքներ", + "TaskRefreshPeople": "Թարմացնել մարդկանց", + "TaskRefreshChannels": "Թարմացնել ալիքները", + "TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը", + "Albums": "Ալբոմներ" +} -- cgit v1.2.3 From 131db9b3806d273fdd20f7adb991a6aef4566f5e Mon Sep 17 00:00:00 2001 From: queeup Date: Wed, 27 Dec 2023 14:44:51 +0000 Subject: Translated using Weblate (Turkish) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/tr/ --- Emby.Server.Implementations/Localization/Core/tr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 6a04115faf..d7a627d127 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -111,7 +111,7 @@ "TaskCleanLogs": "Günlük Dizinini Temizle", "TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve ortam bilgilerini yeniler.", "TaskRefreshLibrary": "Medya Kütüphanesini Tara", - "TaskRefreshChapterImagesDescription": "Sahnelere ayrılmış videolar için küçük resimler oluştur.", + "TaskRefreshChapterImagesDescription": "Bölümlere ayrılmış videolar için küçük resimler oluştur.", "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar", "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.", "TaskCleanActivityLog": "Etkinlik Günlüğünü Temizle", -- cgit v1.2.3 From 260fe6890c1fb2db91bbb97310ef1256984dbdac Mon Sep 17 00:00:00 2001 From: Sergey Date: Tue, 26 Dec 2023 19:56:55 +0000 Subject: Translated using Weblate (Armenian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/hy/ --- Emby.Server.Implementations/Localization/Core/hy.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/hy.json b/Emby.Server.Implementations/Localization/Core/hy.json index c348836fe6..563f842923 100644 --- a/Emby.Server.Implementations/Localization/Core/hy.json +++ b/Emby.Server.Implementations/Localization/Core/hy.json @@ -31,5 +31,9 @@ "TaskRefreshPeople": "Թարմացնել մարդկանց", "TaskRefreshChannels": "Թարմացնել ալիքները", "TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը", - "Albums": "Ալբոմներ" + "Albums": "Ալբոմներ", + "AppDeviceValues": "Հավելված` {0}, Սարք `{1}", + "ChapterNameValue": "Գլուխ {0}", + "Collections": "Հավաքածուներ", + "DeviceOnlineWithName": "{0}-ն միացված է" } -- cgit v1.2.3 From 909d31c8378e664d3575ffab795eb48967781dc2 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Sun, 31 Dec 2023 15:05:19 +0100 Subject: Fix broken code coverage --- tests/coverletArgs.runsettings | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/coverletArgs.runsettings b/tests/coverletArgs.runsettings index 3113957e0a..8e97894487 100644 --- a/tests/coverletArgs.runsettings +++ b/tests/coverletArgs.runsettings @@ -4,14 +4,15 @@ - cobertura + cobertura [coverlet.*.tests?]*,[*]Coverlet.Core*,[*]Moq* Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute false - true + false false + - \ No newline at end of file + -- cgit v1.2.3 From 19fa91bfa36e4f1cb164c10fdc7b12d4240e4bc5 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Sun, 31 Dec 2023 15:24:23 +0100 Subject: SkipAutoProps is true by default but set it explicitly --- tests/coverletArgs.runsettings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/coverletArgs.runsettings b/tests/coverletArgs.runsettings index 8e97894487..7d48373896 100644 --- a/tests/coverletArgs.runsettings +++ b/tests/coverletArgs.runsettings @@ -10,7 +10,7 @@ false false false - + true -- cgit v1.2.3 From 71e028155c062442119af0b5febd5e549f397243 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 16:30:15 +0000 Subject: chore(deps): update dependency metabrainz.musicbrainz to v6 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 90280d98bd..6b99ac807e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,7 @@ - + -- cgit v1.2.3 From e9361d47c0c2dc5032340cc04a56afa04b6e583e Mon Sep 17 00:00:00 2001 From: bene toffix Date: Sun, 31 Dec 2023 18:12:30 +0000 Subject: Translated using Weblate (Catalan) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ca/ --- Emby.Server.Implementations/Localization/Core/ca.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 26290df4d8..c4d8c69479 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -124,5 +124,7 @@ "TaskKeyframeExtractorDescription": "Extreu fotogrames clau dels fitxers de vídeo per crear llistes de reproducció HLS més precises. Aquesta tasca pot durar molt de temps.", "TaskKeyframeExtractor": "Extractor de fotogrames clau", "External": "Extern", - "HearingImpaired": "Discapacitat auditiva" + "HearingImpaired": "Discapacitat auditiva", + "TaskRefreshTrickplayImages": "Generar miniatures de línia de temps", + "TaskRefreshTrickplayImagesDescription": "Crear miniatures de línia de temps per vídeos en les biblioteques habilitades." } -- cgit v1.2.3 From 9fb67424004166423f5857ca01d0f3b524c27b7a Mon Sep 17 00:00:00 2001 From: Dr Ajay Divvela Date: Tue, 2 Jan 2024 03:16:56 +0000 Subject: Translated using Weblate (Telugu) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/te/ --- Emby.Server.Implementations/Localization/Core/te.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/te.json b/Emby.Server.Implementations/Localization/Core/te.json index 24168b6112..7d4422d62f 100644 --- a/Emby.Server.Implementations/Localization/Core/te.json +++ b/Emby.Server.Implementations/Localization/Core/te.json @@ -38,5 +38,18 @@ "HeaderFavoriteSongs": "ఇష్టమైన పాటలు", "HeaderLiveTV": "ప్రత్యక్ష TV", "HeaderNextUp": "తదుపరి", - "HeaderRecordingGroups": "రికార్డింగ్ గుంపులు" + "HeaderRecordingGroups": "రికార్డింగ్ గుంపులు", + "MessageApplicationUpdated": "జెల్లీఫిన్ సర్వర్ అప్‌డేట్ చేయడం పూర్తి అయ్యింది", + "MessageApplicationUpdatedTo": "జెల్లీఫిన్ సర్వర్ {0} వెర్షన్ కి అప్‌డేట్ చెయ్యబడింది", + "MessageServerConfigurationUpdated": "సర్వర్ కన్ఫిగరేషన్ అప్డేట్ చేయబడింది", + "NewVersionIsAvailable": "జెల్లీఫిన్ సర్వర్ యొక్క కొత్త వెర్షన్ డౌన్‌లోడ్ చేసుకోవడానికి అందుబాటులో ఉంది.", + "NotificationOptionApplicationUpdateInstalled": "అప్లికేషన్ అప్‌డేట్ ఇన్‌స్టాల్ చేయబడింది", + "ItemAddedWithName": "{0} లైబ్రరీకి జోడించబడింది", + "ItemRemovedWithName": "లైబ్రరీ నుండి {0} తీసివేయబడింది", + "LabelIpAddressValue": "ఐపీ చిరునామా: {0}", + "LabelRunningTimeValue": "నడుస్తున్న సమయం: {0}", + "Latest": "తాజా", + "NameInstallFailed": "{0} ఇన్‌స్టాలేషన్ విఫలమైంది", + "NameSeasonUnknown": "భాగం తెలియదు", + "NotificationOptionApplicationUpdateAvailable": "అప్లికేషన్ అప్‌డేట్ అందుబాటులో ఉంది" } -- cgit v1.2.3 From 47cdfd796679c0bf03e73c9723256fa13218265c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Jan 2024 23:01:22 +0000 Subject: chore(deps): update alex-page/github-project-automation-plus action to v0.9.0 --- .github/workflows/project-automation.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml index 3637eb16ad..d62f655b30 100644 --- a/.github/workflows/project-automation.yml +++ b/.github/workflows/project-automation.yml @@ -15,7 +15,7 @@ jobs: if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - name: Remove from 'Current Release' project - uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3 + uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') continue-on-error: true with: @@ -24,7 +24,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Release Next' project - uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3 + uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened' continue-on-error: true with: @@ -33,7 +33,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Current Release' project - uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3 + uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') continue-on-error: true with: @@ -47,7 +47,7 @@ jobs: run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)" - name: Move issue to needs triage - uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3 + uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1 continue-on-error: true with: @@ -56,7 +56,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add issue to triage project - uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3 + uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 if: github.event.issue.pull_request == '' && github.event.action == 'opened' continue-on-error: true with: -- cgit v1.2.3 From 29b559089cc63b7d0e38ff349efaa95eaa54d3b6 Mon Sep 17 00:00:00 2001 From: Lukáš Kucharczyk Date: Wed, 3 Jan 2024 16:39:49 +0000 Subject: Translated using Weblate (Czech) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/cs/ --- Emby.Server.Implementations/Localization/Core/cs.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 5da33febe3..1c7bc75b5c 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -83,8 +83,8 @@ "UserDeletedWithName": "Uživatel {0} byl smazán", "UserDownloadingItemWithValues": "{0} stahuje {1}", "UserLockedOutWithName": "Uživatel {0} byl odemčen", - "UserOfflineFromDevice": "{0} se odpojil od {1}", - "UserOnlineFromDevice": "{0} se připojil z {1}", + "UserOfflineFromDevice": "{0} se odpojil ze zařízení {1}", + "UserOnlineFromDevice": "{0} se připojil ze zařízení {1}", "UserPasswordChangedWithName": "Provedena změna hesla pro uživatele {0}", "UserPolicyUpdatedWithName": "Zásady uživatele pro {0} byly aktualizovány", "UserStartedPlayingItemWithValues": "{0} spustil přehrávání {1}", -- cgit v1.2.3 From aa71129cffd8d1faf8f862449ae98c5c7888ba53 Mon Sep 17 00:00:00 2001 From: Nyanmisaka Date: Fri, 5 Jan 2024 07:31:45 +0800 Subject: Use -noauto{scale,rotate} for disabling auto filters (#10810) `-auto{scale,rotate} 0` has been dropped in upstream FFmpeg. Signed-off-by: nyanmisaka --- MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 6a16d421cb..400e7f40fb 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1068,7 +1068,7 @@ namespace MediaBrowser.Controller.MediaEncoding } // hw transpose filters should be added manually. - args.Append(" -autorotate 0"); + args.Append(" -noautorotate"); return args.ToString().Trim(); } @@ -1159,7 +1159,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isSwDecoder = string.IsNullOrEmpty(GetHardwareVideoDecoder(state, options)); if (!isSwDecoder && _mediaEncoder.EncoderVersion >= new Version(4, 4)) { - arg.Append(" -autoscale 0"); + arg.Append(" -noautoscale"); } return arg.ToString(); -- cgit v1.2.3 From b39dc1eb016e0801d8cd6778045ba2c8c58dc614 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:46:57 -0700 Subject: chore(deps): update prometheus-net monorepo to v8.2.1 (#10804) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6b99ac807e..11e7f17c65 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -54,9 +54,9 @@ - + - + -- cgit v1.2.3 From a23ead735abb2816a2a2813b3defb2a10c489b16 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 5 Jan 2024 07:39:13 +0000 Subject: chore(deps): update dependency xunit to v2.6.5 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 11e7f17c65..707b6ee5ff 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -85,6 +85,6 @@ - + -- cgit v1.2.3 From 6046512a4fc5024a8e7fa2a03e1c3cc57ec1c28c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 5 Jan 2024 12:45:10 +0000 Subject: chore(deps): update dependency playlistsnet to v1.4.1 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 11e7f17c65..abc7a5d2c1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -53,7 +53,7 @@ - + -- cgit v1.2.3 From a45a8e34a7f1906ee45ea4db9cfc50c3b9d30aa8 Mon Sep 17 00:00:00 2001 From: Vasyl Belynets Date: Fri, 5 Jan 2024 11:00:37 +0000 Subject: Translated using Weblate (Ukrainian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/uk/ --- Emby.Server.Implementations/Localization/Core/uk.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index bd5398f083..4c3bf2f10b 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -22,12 +22,12 @@ "Favorites": "Обрані", "DeviceOnlineWithName": "Пристрій {0} підключився", "DeviceOfflineWithName": "Пристрій {0} відключився", - "Collections": "Добірки", - "ChapterNameValue": "Розділ {0}", + "Collections": "Колекції", + "ChapterNameValue": "Сцена {0}", "Channels": "Канали", - "CameraImageUploadedFrom": "Нова фотографія завантажена з {0}", + "CameraImageUploadedFrom": "Нову фотографію завантажено з {0}", "Books": "Книги", - "AuthenticationSucceededWithUserName": "{0} успішно автентифіковано", + "AuthenticationSucceededWithUserName": "{0} успішно авторизовано", "Artists": "Виконавці", "Application": "Додаток", "AppDeviceValues": "Додаток: {0}, Пристрій: {1}", -- cgit v1.2.3 From ef955818bfc33159203bf1fc27a49f336fd11edd Mon Sep 17 00:00:00 2001 From: newton181 Date: Thu, 4 Jan 2024 21:30:48 +0000 Subject: Translated using Weblate (Spanish (Latin America)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/es_419/ --- Emby.Server.Implementations/Localization/Core/es_419.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index 3d5c046336..c6863ff368 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -123,5 +123,7 @@ "External": "Externo", "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reproducción HLS más precisas. Esta tarea puede durar mucho tiempo.", "TaskKeyframeExtractor": "Extractor de Fotogramas Clave", - "HearingImpaired": "Discapacidad auditiva" + "HearingImpaired": "Discapacidad auditiva", + "TaskRefreshTrickplayImagesDescription": "Crea previsualizaciones para la barra de reproducción en las bibliotecas habilitadas.", + "TaskRefreshTrickplayImages": "Generar imágenes de la barra de reproducción" } -- cgit v1.2.3 From f6b865f03a035859adf45352dd57d27a756b2dee Mon Sep 17 00:00:00 2001 From: George Salukvadze Date: Thu, 4 Jan 2024 17:43:40 +0000 Subject: Translated using Weblate (Georgian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ka/ --- Emby.Server.Implementations/Localization/Core/ka.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/ka.json b/Emby.Server.Implementations/Localization/Core/ka.json index dbbc81eeb2..5368b8eb6e 100644 --- a/Emby.Server.Implementations/Localization/Core/ka.json +++ b/Emby.Server.Implementations/Localization/Core/ka.json @@ -123,5 +123,7 @@ "TaskUpdatePluginsDescription": "ავტომატურად განახლებადად მონიშნული დამატებების განახლებების გადმოწერა და დაყენება.", "TaskCleanTranscodeDescription": "ერთ დღეზე უფრო ძველი ტრანსკოდირების ფაილების წაშლა.", "TaskDownloadMissingSubtitlesDescription": "მეტამონაცემებზე დაყრდნობით ინტერნეტში ნაკლული სუბტიტრების ძებნა.", - "TaskOptimizeDatabaseDescription": "ბაზს შეკუშვა და ადგილის გათავისუფლება. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს." + "TaskOptimizeDatabaseDescription": "ბაზს შეკუშვა და ადგილის გათავისუფლება. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს.", + "TaskRefreshTrickplayImagesDescription": "ქმნის trickplay წინასწარ ხედებს ვიდეოებისთვის ჩართულ ბიბლიოთეკებში.", + "TaskRefreshTrickplayImages": "Trickplay სურათების გენერირება" } -- cgit v1.2.3 From 55916a09eb4c88b12906e78d56efb7a67d3dad7e Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Sat, 6 Jan 2024 21:33:48 +0100 Subject: Remove some unused client capabilities and sync code (#10812) --- Emby.Server.Implementations/Dto/DtoService.cs | 9 --------- Emby.Server.Implementations/LiveTv/LiveTvManager.cs | 1 - Emby.Server.Implementations/Session/SessionManager.cs | 1 - Jellyfin.Api/Controllers/DevicesController.cs | 5 ++--- Jellyfin.Api/Controllers/SessionController.cs | 3 --- Jellyfin.Api/Controllers/UserViewsController.cs | 1 - .../Models/SessionDtos/ClientCapabilitiesDto.cs | 18 ------------------ .../Devices/DeviceManager.cs | 6 +----- MediaBrowser.Controller/Devices/IDeviceManager.cs | 3 +-- MediaBrowser.Controller/Entities/BaseItem.cs | 2 -- MediaBrowser.Model/Dto/BaseItemDto.cs | 5 ----- MediaBrowser.Model/Querying/ItemFields.cs | 7 ------- MediaBrowser.Model/Session/ClientCapabilities.cs | 6 ------ 13 files changed, 4 insertions(+), 63 deletions(-) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 44b97e8b83..d0d5bb81c1 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -418,15 +418,6 @@ namespace Emby.Server.Implementations.Dto { dto.PlayAccess = item.GetPlayAccess(user); } - - if (options.ContainsField(ItemFields.BasicSyncInfo)) - { - var userCanSync = user is not null && user.HasPermission(PermissionKind.EnableContentDownloading); - if (userCanSync && item.SupportsExternalTransfer) - { - dto.SupportsSync = true; - } - } } private static int GetChildCount(Folder folder, User user) diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs index 0544e2a4b7..ca8e204d7c 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -1095,7 +1095,6 @@ namespace Emby.Server.Implementations.LiveTv // Load these now which will prefetch metadata var dtoOptions = new DtoOptions(); var fields = dtoOptions.Fields.ToList(); - fields.Remove(ItemFields.BasicSyncInfo); dtoOptions.Fields = fields.ToArray(); progress.Report(100); diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index e8e63d286d..6f599e4c7c 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1670,7 +1670,6 @@ namespace Emby.Server.Implementations.Session var fields = dtoOptions.Fields.ToList(); - fields.Remove(ItemFields.BasicSyncInfo); fields.Remove(ItemFields.CanDelete); fields.Remove(ItemFields.CanDownload); fields.Remove(ItemFields.ChildCount); diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index aa200a7221..6d9ec343e0 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -42,16 +42,15 @@ public class DevicesController : BaseJellyfinApiController /// /// Get Devices. /// - /// Gets or sets a value indicating whether [supports synchronize]. /// Gets or sets the user identifier. /// Devices retrieved. /// An containing the list of devices. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) + public async Task>> GetDevices([FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false); + return await _deviceManager.GetDevicesForUser(userId).ConfigureAwait(false); } /// diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index fdebb3d450..083515a949 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -385,7 +385,6 @@ public class SessionController : BaseJellyfinApiController /// A list of playable media types, comma delimited. Audio, Video, Book, Photo. /// A list of supported remote control commands, comma delimited. /// Determines whether media can be played remotely.. - /// Determines whether sync is supported. /// Determines whether the device supports a unique identifier. /// Capabilities posted. /// A . @@ -397,7 +396,6 @@ public class SessionController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] playableMediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, [FromQuery] bool supportsMediaControl = false, - [FromQuery] bool supportsSync = false, [FromQuery] bool supportsPersistentIdentifier = true) { if (string.IsNullOrWhiteSpace(id)) @@ -410,7 +408,6 @@ public class SessionController : BaseJellyfinApiController PlayableMediaTypes = playableMediaTypes, SupportedCommands = supportedCommands, SupportsMediaControl = supportsMediaControl, - SupportsSync = supportsSync, SupportsPersistentIdentifier = supportsPersistentIdentifier }); return NoContent(); diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index 0ffa3ab1ad..035d044741 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -90,7 +90,6 @@ public class UserViewsController : BaseJellyfinApiController fields.Add(ItemFields.PrimaryImageAspectRatio); fields.Add(ItemFields.DisplayPreferencesId); - fields.Remove(ItemFields.BasicSyncInfo); dtoOptions.Fields = fields.ToArray(); var user = _userManager.GetUserById(userId); diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs index b021771a0f..acd3f29e34 100644 --- a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs +++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs @@ -30,26 +30,11 @@ public class ClientCapabilitiesDto /// public bool SupportsMediaControl { get; set; } - /// - /// Gets or sets a value indicating whether session supports content uploading. - /// - public bool SupportsContentUploading { get; set; } - - /// - /// Gets or sets the message callback url. - /// - public string? MessageCallbackUrl { get; set; } - /// /// Gets or sets a value indicating whether session supports a persistent identifier. /// public bool SupportsPersistentIdentifier { get; set; } - /// - /// Gets or sets a value indicating whether session supports sync. - /// - public bool SupportsSync { get; set; } - /// /// Gets or sets the device profile. /// @@ -76,10 +61,7 @@ public class ClientCapabilitiesDto PlayableMediaTypes = PlayableMediaTypes, SupportedCommands = SupportedCommands, SupportsMediaControl = SupportsMediaControl, - SupportsContentUploading = SupportsContentUploading, - MessageCallbackUrl = MessageCallbackUrl, SupportsPersistentIdentifier = SupportsPersistentIdentifier, - SupportsSync = SupportsSync, DeviceProfile = DeviceProfile, AppStoreUrl = AppStoreUrl, IconUrl = IconUrl diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index d8eee12467..5e5d52b6b3 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -167,7 +167,7 @@ namespace Jellyfin.Server.Implementations.Devices } /// - public async Task> GetDevicesForUser(Guid? userId, bool? supportsSync) + public async Task> GetDevicesForUser(Guid? userId) { var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) @@ -178,10 +178,6 @@ namespace Jellyfin.Server.Implementations.Devices .ThenBy(d => d.DeviceId) .SelectMany(d => dbContext.DeviceOptions.Where(o => o.DeviceId == d.DeviceId).DefaultIfEmpty(), (d, o) => new { Device = d, Options = o }) .AsAsyncEnumerable(); - if (supportsSync.HasValue) - { - sessions = sessions.Where(i => GetCapabilities(i.Device.DeviceId).SupportsSync == supportsSync.Value); - } if (userId.HasValue) { diff --git a/MediaBrowser.Controller/Devices/IDeviceManager.cs b/MediaBrowser.Controller/Devices/IDeviceManager.cs index 8362db1a71..eb181dcc4c 100644 --- a/MediaBrowser.Controller/Devices/IDeviceManager.cs +++ b/MediaBrowser.Controller/Devices/IDeviceManager.cs @@ -59,9 +59,8 @@ namespace MediaBrowser.Controller.Devices /// Gets the devices. /// /// The user's id, or null. - /// A value indicating whether the device supports sync, or null. /// IEnumerable<DeviceInfo>. - Task> GetDevicesForUser(Guid? userId, bool? supportsSync); + Task> GetDevicesForUser(Guid? userId); Task DeleteDevice(Device device); diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 7c04fcbfc8..fdbceac961 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -773,8 +773,6 @@ namespace MediaBrowser.Controller.Entities /// The remote trailers. public IReadOnlyList RemoteTrailers { get; set; } - public virtual bool SupportsExternalTransfer => false; - public virtual double GetDefaultPrimaryImageAspectRatio() { return 0; diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index d257eab920..cfff717db2 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -85,11 +85,6 @@ namespace MediaBrowser.Model.Dto public string PreferredMetadataCountryCode { get; set; } - /// - /// Gets or sets a value indicating whether [supports synchronize]. - /// - public bool? SupportsSync { get; set; } - public string Container { get; set; } /// diff --git a/MediaBrowser.Model/Querying/ItemFields.cs b/MediaBrowser.Model/Querying/ItemFields.cs index 242a1c6e99..49d7c0bcb0 100644 --- a/MediaBrowser.Model/Querying/ItemFields.cs +++ b/MediaBrowser.Model/Querying/ItemFields.cs @@ -175,13 +175,6 @@ namespace MediaBrowser.Model.Querying /// Studios, - BasicSyncInfo, - - /// - /// The synchronize information. - /// - SyncInfo, - /// /// The taglines of the item. /// diff --git a/MediaBrowser.Model/Session/ClientCapabilities.cs b/MediaBrowser.Model/Session/ClientCapabilities.cs index 7fefce9cd5..597845fc17 100644 --- a/MediaBrowser.Model/Session/ClientCapabilities.cs +++ b/MediaBrowser.Model/Session/ClientCapabilities.cs @@ -23,14 +23,8 @@ namespace MediaBrowser.Model.Session public bool SupportsMediaControl { get; set; } - public bool SupportsContentUploading { get; set; } - - public string MessageCallbackUrl { get; set; } - public bool SupportsPersistentIdentifier { get; set; } - public bool SupportsSync { get; set; } - public DeviceProfile DeviceProfile { get; set; } public string AppStoreUrl { get; set; } -- cgit v1.2.3 From 82f93afa22c9695f960903f7b90c7a2c8b23af74 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Sat, 6 Jan 2024 15:33:58 -0500 Subject: Fix More Live TV Warnings (#10818) * Fix CA1819 in LiveTvManager * Call ConfigureAwait in ChannelManager --- .../Channels/ChannelManager.cs | 35 +++++++++++++++------- .../LiveTv/LiveTvManager.cs | 4 +-- MediaBrowser.Controller/LiveTv/ILiveTvManager.cs | 2 +- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 8279acb058..c505e8fe13 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -812,11 +812,16 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - await using FileStream jsonStream = AsyncFile.OpenRead(cachePath); - var cachedResult = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (cachedResult is not null) + var jsonStream = AsyncFile.OpenRead(cachePath); + await using (jsonStream.ConfigureAwait(false)) { - return null; + var cachedResult = await JsonSerializer + .DeserializeAsync(jsonStream, _jsonOptions, cancellationToken) + .ConfigureAwait(false); + if (cachedResult is not null) + { + return null; + } } } } @@ -835,11 +840,16 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - await using FileStream jsonStream = AsyncFile.OpenRead(cachePath); - var cachedResult = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (cachedResult is not null) + var jsonStream = AsyncFile.OpenRead(cachePath); + await using (jsonStream.ConfigureAwait(false)) { - return null; + var cachedResult = await JsonSerializer + .DeserializeAsync(jsonStream, _jsonOptions, cancellationToken) + .ConfigureAwait(false); + if (cachedResult is not null) + { + return null; + } } } } @@ -867,7 +877,7 @@ namespace Emby.Server.Implementations.Channels throw new InvalidOperationException("Channel returned a null result from GetChannelItems"); } - await CacheResponse(result, cachePath); + await CacheResponse(result, cachePath).ConfigureAwait(false); return result; } @@ -883,8 +893,11 @@ namespace Emby.Server.Implementations.Channels { Directory.CreateDirectory(Path.GetDirectoryName(path)); - await using FileStream createStream = File.Create(path); - await JsonSerializer.SerializeAsync(createStream, result, _jsonOptions).ConfigureAwait(false); + var createStream = File.Create(path); + await using (createStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(createStream, result, _jsonOptions).ConfigureAwait(false); + } } catch (Exception ex) { diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs index ca8e204d7c..426165de61 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -105,9 +105,9 @@ namespace Emby.Server.Implementations.LiveTv /// The services. public IReadOnlyList Services => _services; - public ITunerHost[] TunerHosts => _tunerHosts; + public IReadOnlyList TunerHosts => _tunerHosts; - public IListingsProvider[] ListingProviders => _listingProviders; + public IReadOnlyList ListingProviders => _listingProviders; private LiveTvOptions GetConfiguration() { diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 3b6a16dee3..4206159e7b 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -36,7 +36,7 @@ namespace MediaBrowser.Controller.LiveTv /// The services. IReadOnlyList Services { get; } - IListingsProvider[] ListingProviders { get; } + IReadOnlyList ListingProviders { get; } /// /// Gets the new timer defaults asynchronous. -- cgit v1.2.3 From 43b32b0d94d4deef49bbca735dc50447acdb9250 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Sat, 6 Jan 2024 15:34:09 -0500 Subject: Auto Discovery Cleanup (#10793) * Call GetSmartApiUrl directly in UdpServer.RespondToV2Message GetSmartApiUrl already returns PublishedServerUrl if set. * Rewrite auto discovery using UdpClient and BackgroundService * Respect network address settings in AutoDiscoveryHost * Always listen on broadcast address in Linux for auto-discovery * Await udp server tasks in AutoDiscoveryHost * Only bind to broadcast addresses for IPv4 * Only bind to broadcast if IPv4 is enabled --- Jellyfin.Server/Startup.cs | 4 +- src/Jellyfin.Networking/AutoDiscoveryHost.cs | 129 ++++++++++++++++++++++ src/Jellyfin.Networking/Udp/UdpServer.cs | 136 ----------------------- src/Jellyfin.Networking/UdpServerEntryPoint.cs | 143 ------------------------- 4 files changed, 132 insertions(+), 280 deletions(-) create mode 100644 src/Jellyfin.Networking/AutoDiscoveryHost.cs delete mode 100644 src/Jellyfin.Networking/Udp/UdpServer.cs delete mode 100644 src/Jellyfin.Networking/UdpServerEntryPoint.cs diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index aa7be9109e..1030c6f5fc 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -6,6 +6,7 @@ using System.Net.Mime; using System.Text; using Jellyfin.Api.Middleware; using Jellyfin.MediaEncoding.Hls.Extensions; +using Jellyfin.Networking; using Jellyfin.Networking.HappyEyeballs; using Jellyfin.Server.Extensions; using Jellyfin.Server.HealthChecks; @@ -13,7 +14,6 @@ using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Extensions; using Jellyfin.Server.Infrastructure; using MediaBrowser.Common.Net; -using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Extensions; using Microsoft.AspNetCore.Builder; @@ -121,6 +121,8 @@ namespace Jellyfin.Server .AddCheck>(nameof(JellyfinDbContext)); services.AddHlsPlaylistGenerator(); + + services.AddHostedService(); } /// diff --git a/src/Jellyfin.Networking/AutoDiscoveryHost.cs b/src/Jellyfin.Networking/AutoDiscoveryHost.cs new file mode 100644 index 0000000000..5624c4ed13 --- /dev/null +++ b/src/Jellyfin.Networking/AutoDiscoveryHost.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Model.ApiClient; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Networking; + +/// +/// responsible for responding to auto-discovery messages. +/// +public sealed class AutoDiscoveryHost : BackgroundService +{ + /// + /// The port to listen on for auto-discovery messages. + /// + private const int PortNumber = 7359; + + private readonly ILogger _logger; + private readonly IServerApplicationHost _appHost; + private readonly IConfigurationManager _configurationManager; + private readonly INetworkManager _networkManager; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + public AutoDiscoveryHost( + ILogger logger, + IServerApplicationHost appHost, + IConfigurationManager configurationManager, + INetworkManager networkManager) + { + _logger = logger; + _appHost = appHost; + _configurationManager = configurationManager; + _networkManager = networkManager; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var networkConfig = _configurationManager.GetNetworkConfiguration(); + if (!networkConfig.AutoDiscovery) + { + return; + } + + var udpServers = new List(); + // Linux needs to bind to the broadcast addresses to receive broadcast traffic + if (OperatingSystem.IsLinux() && networkConfig.EnableIPv4) + { + udpServers.Add(ListenForAutoDiscoveryMessage(IPAddress.Broadcast, stoppingToken)); + } + + udpServers.AddRange(_networkManager.GetInternalBindAddresses() + .Select(intf => ListenForAutoDiscoveryMessage( + OperatingSystem.IsLinux() && intf.AddressFamily == AddressFamily.InterNetwork + ? NetworkUtils.GetBroadcastAddress(intf.Subnet) + : intf.Address, + stoppingToken))); + + await Task.WhenAll(udpServers).ConfigureAwait(false); + } + + private async Task ListenForAutoDiscoveryMessage(IPAddress address, CancellationToken cancellationToken) + { + using var udpClient = new UdpClient(new IPEndPoint(address, PortNumber)); + udpClient.MulticastLoopback = false; + + while (!cancellationToken.IsCancellationRequested) + { + try + { + var result = await udpClient.ReceiveAsync(cancellationToken).ConfigureAwait(false); + var text = Encoding.UTF8.GetString(result.Buffer); + if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase)) + { + await RespondToV2Message(udpClient, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); + } + } + catch (SocketException ex) + { + _logger.LogError(ex, "Failed to receive data from socket"); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Broadcast socket operation cancelled"); + } + } + } + + private async Task RespondToV2Message(UdpClient udpClient, IPEndPoint endpoint, CancellationToken cancellationToken) + { + var localUrl = _appHost.GetSmartApiUrl(endpoint.Address); + if (string.IsNullOrEmpty(localUrl)) + { + _logger.LogWarning("Unable to respond to server discovery request because the local ip address could not be determined."); + return; + } + + var response = new ServerDiscoveryInfo(localUrl, _appHost.SystemId, _appHost.FriendlyName); + + try + { + _logger.LogDebug("Sending AutoDiscovery response"); + await udpClient + .SendAsync(JsonSerializer.SerializeToUtf8Bytes(response).AsMemory(), endpoint, cancellationToken) + .ConfigureAwait(false); + } + catch (SocketException ex) + { + _logger.LogError(ex, "Error sending response message"); + } + } +} diff --git a/src/Jellyfin.Networking/Udp/UdpServer.cs b/src/Jellyfin.Networking/Udp/UdpServer.cs deleted file mode 100644 index b130a5a5ff..0000000000 --- a/src/Jellyfin.Networking/Udp/UdpServer.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller; -using MediaBrowser.Model.ApiClient; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; - -namespace Jellyfin.Networking.Udp; - -/// -/// Provides a Udp Server. -/// -public sealed class UdpServer : IDisposable -{ - /// - /// The _logger. - /// - private readonly ILogger _logger; - private readonly IServerApplicationHost _appHost; - private readonly IConfiguration _config; - - private readonly byte[] _receiveBuffer = new byte[8192]; - - private readonly Socket _udpSocket; - private readonly IPEndPoint _endpoint; - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The application host. - /// The configuration manager. - /// The bind address. - /// The port. - public UdpServer( - ILogger logger, - IServerApplicationHost appHost, - IConfiguration configuration, - IPAddress bindAddress, - int port) - { - _logger = logger; - _appHost = appHost; - _config = configuration; - - _endpoint = new IPEndPoint(bindAddress, port); - - _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp) - { - MulticastLoopback = false, - }; - _udpSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - } - - private async Task RespondToV2Message(EndPoint endpoint, CancellationToken cancellationToken) - { - string? localUrl = _config[AddressOverrideKey]; - if (string.IsNullOrEmpty(localUrl)) - { - localUrl = _appHost.GetSmartApiUrl(((IPEndPoint)endpoint).Address); - } - - if (string.IsNullOrEmpty(localUrl)) - { - _logger.LogWarning("Unable to respond to server discovery request because the local ip address could not be determined."); - return; - } - - var response = new ServerDiscoveryInfo(localUrl, _appHost.SystemId, _appHost.FriendlyName); - - try - { - _logger.LogDebug("Sending AutoDiscovery response"); - await _udpSocket.SendToAsync(JsonSerializer.SerializeToUtf8Bytes(response), SocketFlags.None, endpoint, cancellationToken).ConfigureAwait(false); - } - catch (SocketException ex) - { - _logger.LogError(ex, "Error sending response message"); - } - } - - /// - /// Starts the specified port. - /// - /// The cancellation token to cancel operation. - public void Start(CancellationToken cancellationToken) - { - _udpSocket.Bind(_endpoint); - - _ = Task.Run(async () => await BeginReceiveAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - } - - private async Task BeginReceiveAsync(CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - try - { - var endpoint = (EndPoint)new IPEndPoint(IPAddress.Any, 0); - var result = await _udpSocket.ReceiveFromAsync(_receiveBuffer, endpoint, cancellationToken).ConfigureAwait(false); - var text = Encoding.UTF8.GetString(_receiveBuffer, 0, result.ReceivedBytes); - if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase)) - { - await RespondToV2Message(result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); - } - } - catch (SocketException ex) - { - _logger.LogError(ex, "Failed to receive data from socket"); - } - catch (OperationCanceledException) - { - _logger.LogDebug("Broadcast socket operation cancelled"); - } - } - } - - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _udpSocket.Dispose(); - _disposed = true; - } -} diff --git a/src/Jellyfin.Networking/UdpServerEntryPoint.cs b/src/Jellyfin.Networking/UdpServerEntryPoint.cs deleted file mode 100644 index 61180c3c0f..0000000000 --- a/src/Jellyfin.Networking/UdpServerEntryPoint.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Networking.Udp; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Plugins; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; - -namespace Jellyfin.Networking; - -/// -/// Class responsible for registering all UDP broadcast endpoints and their handlers. -/// -public sealed class UdpServerEntryPoint : IServerEntryPoint -{ - /// - /// The port of the UDP server. - /// - public const int PortNumber = 7359; - - /// - /// The logger. - /// - private readonly ILogger _logger; - private readonly IServerApplicationHost _appHost; - private readonly IConfiguration _config; - private readonly IConfigurationManager _configurationManager; - private readonly INetworkManager _networkManager; - - /// - /// The UDP server. - /// - private readonly List _udpServers; - private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public UdpServerEntryPoint( - ILogger logger, - IServerApplicationHost appHost, - IConfiguration configuration, - IConfigurationManager configurationManager, - INetworkManager networkManager) - { - _logger = logger; - _appHost = appHost; - _config = configuration; - _configurationManager = configurationManager; - _networkManager = networkManager; - _udpServers = new List(); - } - - /// - public Task RunAsync() - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (!_configurationManager.GetNetworkConfiguration().AutoDiscovery) - { - return Task.CompletedTask; - } - - try - { - // Linux needs to bind to the broadcast addresses to get broadcast traffic - // Windows receives broadcast fine when binding to just the interface, it is unable to bind to broadcast addresses - if (OperatingSystem.IsLinux()) - { - // Add global broadcast listener - var server = new UdpServer(_logger, _appHost, _config, IPAddress.Broadcast, PortNumber); - server.Start(_cancellationTokenSource.Token); - _udpServers.Add(server); - - // Add bind address specific broadcast listeners - // IPv6 is currently unsupported - var validInterfaces = _networkManager.GetInternalBindAddresses().Where(i => i.AddressFamily == AddressFamily.InterNetwork); - foreach (var intf in validInterfaces) - { - var broadcastAddress = NetworkUtils.GetBroadcastAddress(intf.Subnet); - _logger.LogDebug("Binding UDP server to {Address} on port {PortNumber}", broadcastAddress, PortNumber); - - server = new UdpServer(_logger, _appHost, _config, broadcastAddress, PortNumber); - server.Start(_cancellationTokenSource.Token); - _udpServers.Add(server); - } - } - else - { - // Add bind address specific broadcast listeners - // IPv6 is currently unsupported - var validInterfaces = _networkManager.GetInternalBindAddresses().Where(i => i.AddressFamily == AddressFamily.InterNetwork); - foreach (var intf in validInterfaces) - { - var intfAddress = intf.Address; - _logger.LogDebug("Binding UDP server to {Address} on port {PortNumber}", intfAddress, PortNumber); - - var server = new UdpServer(_logger, _appHost, _config, intfAddress, PortNumber); - server.Start(_cancellationTokenSource.Token); - _udpServers.Add(server); - } - } - } - catch (SocketException ex) - { - _logger.LogWarning(ex, "Unable to start AutoDiscovery listener on UDP port {PortNumber}", PortNumber); - } - - return Task.CompletedTask; - } - - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _cancellationTokenSource.Cancel(); - _cancellationTokenSource.Dispose(); - foreach (var server in _udpServers) - { - server.Dispose(); - } - - _udpServers.Clear(); - _disposed = true; - } -} -- cgit v1.2.3 From 5ff7c17daf706250ff6052cfd3de2e946a8e6e00 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Sat, 6 Jan 2024 22:35:15 +0200 Subject: Feature/enable gh codespaces (#10324) * Added BindAll network Manager * Updated docs * Update ApplicationHost.cs resolved merge conflicts * Fixed merge issues * Updated Dev container to dotnet * Update Emby.Server.Implementations/ApplicationHost.cs Co-authored-by: Cody Robibero * Updates container to include dependencies * Resolved merge conflicts * Updated container config to automate setup more * Updated readme * Enabled auto loading of extensions * fixed recommended list not parsable * Removed obsolete code * Reverted change to virtualize GetAllBindInterfaces * Updated Readme * Update devcontainer.json --------- Co-authored-by: Cody Robibero --- .devcontainer/devcontainer.json | 28 ++++++++++++++++++++++++++++ .vscode/extensions.json | 10 ++++------ README.md | 11 +++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..063901c800 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +{ + "name": "Development Jellyfin Server", + "image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy", + // restores nuget packages, installs the dotnet workloads and installs the dev https certificate + "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust", + // reads the extensions list and installs them + "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", + "features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "none", + "dotnetRuntimeVersions": "8.0", + "aspNetCoreRuntimeVersions": "8.0" + }, + "ghcr.io/devcontainers-contrib/features/apt-packages:1": { + "preserve_apt_list": false, + "packages": ["libfontconfig1"] + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "dockerDashComposeVersion": "v2" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {} + }, + "hostRequirements": { + "memory": "8gb", + "cpus": 4 + } +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 59d9452fed..d738e9fba4 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,13 +1,11 @@ { - // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. - // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp - - // List of extensions which should be recommended for users of this workspace. "recommendations": [ "ms-dotnettools.csharp", - "editorconfig.editorconfig" + "editorconfig.editorconfig", + "GitHub.vscode-github-actions", + "ms-dotnettools.vscode-dotnet-runtime", + "ms-dotnettools.csdevkit" ], - // List of extensions recommended by VS Code that should not be recommended for users of this workspace. "unwantedRecommendations": [ ] diff --git a/README.md b/README.md index 15dd0ae679..62ef21334d 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,17 @@ cd Jellyfin.Server/bin/Debug/net8.0 # Change into the build output directory 2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`. +### Running from GH-Codespaces + +As Jellyfin will run on a container on a github hosted server, JF needs to handle some things differently. +**NOTE:** If you want to access the JF instance from outside, like with a WebClient on another PC, remember to set the "ports" in the lower VsCode window to public. + +#### FFmpeg installation. +Because sometimes you need FFMPEG to test certain cases, follow the instructions from the wiki on the dev enviorment: +https://jellyfin.org/docs/general/installation/linux/#ffmpeg-installation + +**NOTE:** When first opening the server instance with any WebUI, you will be send to the login instead of the setup page. Refresh the login page once and you should be redirected to the Setup. + ### Running The Tests This repository also includes unit tests that are used to validate functionality as part of a CI pipeline on Azure. There are several ways to run these tests. -- cgit v1.2.3 From 04dddd3a7b3911ef9a3e83c85aa5419e8db2fa38 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Sat, 6 Jan 2024 15:43:48 -0500 Subject: Remove UserManager Cache (#10781) * Remove redundant user cache * Use DI for IPasswordResetProvider and IAuthenticationProvider --- .../Users/UserManager.cs | 169 +++++++++++---------- Jellyfin.Server/CoreAppHost.cs | 4 + 2 files changed, 92 insertions(+), 81 deletions(-) diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 990b9a5bd4..cc464a7a3d 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -1,7 +1,7 @@ #pragma warning disable CA1307 +#pragma warning disable CA1309 // Use ordinal string comparison - EF can't translate this using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -46,8 +46,6 @@ namespace Jellyfin.Server.Implementations.Users private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IDictionary _users; - /// /// Initializes a new instance of the class. /// @@ -58,6 +56,8 @@ namespace Jellyfin.Server.Implementations.Users /// The image processor. /// The logger. /// The system config manager. + /// The password reset providers. + /// The authentication providers. public UserManager( IDbContextFactory dbProvider, IEventManager eventManager, @@ -65,7 +65,9 @@ namespace Jellyfin.Server.Implementations.Users IApplicationHost appHost, IImageProcessor imageProcessor, ILogger logger, - IServerConfigurationManager serverConfigurationManager) + IServerConfigurationManager serverConfigurationManager, + IEnumerable passwordResetProviders, + IEnumerable authenticationProviders) { _dbProvider = dbProvider; _eventManager = eventManager; @@ -75,35 +77,36 @@ namespace Jellyfin.Server.Implementations.Users _logger = logger; _serverConfigurationManager = serverConfigurationManager; - _passwordResetProviders = appHost.GetExports(); - _authenticationProviders = appHost.GetExports(); + _passwordResetProviders = passwordResetProviders.ToList(); + _authenticationProviders = authenticationProviders.ToList(); _invalidAuthProvider = _authenticationProviders.OfType().First(); _defaultAuthenticationProvider = _authenticationProviders.OfType().First(); _defaultPasswordResetProvider = _passwordResetProviders.OfType().First(); - - _users = new ConcurrentDictionary(); - using var dbContext = _dbProvider.CreateDbContext(); - foreach (var user in dbContext.Users - .AsSplitQuery() - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .AsEnumerable()) - { - _users.Add(user.Id, user); - } } /// public event EventHandler>? OnUserUpdated; /// - public IEnumerable Users => _users.Values; + public IEnumerable Users + { + get + { + using var dbContext = _dbProvider.CreateDbContext(); + return GetUsersInternal(dbContext).ToList(); + } + } /// - public IEnumerable UsersIds => _users.Keys; + public IEnumerable UsersIds + { + get + { + using var dbContext = _dbProvider.CreateDbContext(); + return dbContext.Users.Select(u => u.Id).ToList(); + } + } // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness @@ -119,8 +122,8 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Guid can't be empty", nameof(id)); } - _users.TryGetValue(id, out var user); - return user; + using var dbContext = _dbProvider.CreateDbContext(); + return GetUsersInternal(dbContext).FirstOrDefault(u => u.Id.Equals(id)); } /// @@ -131,7 +134,9 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Invalid username", nameof(name)); } - return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase)); + using var dbContext = _dbProvider.CreateDbContext(); + return GetUsersInternal(dbContext) + .FirstOrDefault(u => string.Equals(u.Username, name)); } /// @@ -196,8 +201,6 @@ namespace Jellyfin.Server.Implementations.Users user.AddDefaultPermissions(); user.AddDefaultPreferences(); - _users.Add(user.Id, user); - return user; } @@ -232,40 +235,46 @@ namespace Jellyfin.Server.Implementations.Users /// public async Task DeleteUserAsync(Guid userId) { - if (!_users.TryGetValue(userId, out var user)) - { - throw new ResourceNotFoundException(nameof(userId)); - } + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - if (_users.Count == 1) + await using (dbContext.ConfigureAwait(false)) { - throw new InvalidOperationException(string.Format( - CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one user in the system.", - user.Username)); - } + var user = await dbContext.Users + .AsSingleQuery() + .Include(u => u.Permissions) + .FirstOrDefaultAsync(u => u.Id.Equals(userId)) + .ConfigureAwait(false); + if (user is null) + { + throw new ResourceNotFoundException(nameof(userId)); + } - if (user.HasPermission(PermissionKind.IsAdministrator) - && Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) - { - throw new ArgumentException( - string.Format( + if (await dbContext.Users.CountAsync().ConfigureAwait(false) == 1) + { + throw new InvalidOperationException(string.Format( CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", - user.Username), - nameof(userId)); - } + "The user '{0}' cannot be deleted because there must be at least one user in the system.", + user.Username)); + } + + if (user.HasPermission(PermissionKind.IsAdministrator) + && await dbContext.Users + .CountAsync(u => u.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value)) + .ConfigureAwait(false) == 1) + { + throw new ArgumentException( + string.Format( + CultureInfo.InvariantCulture, + "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", + user.Username), + nameof(userId)); + } - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { dbContext.Users.Remove(user); await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - _users.Remove(userId); - - await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); + await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); + } } /// @@ -532,23 +541,23 @@ namespace Jellyfin.Server.Implementations.Users /// public async Task InitializeAsync() { - // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. - if (_users.Any()) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - return; - } + // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. + if (await dbContext.Users.AnyAsync().ConfigureAwait(false)) + { + return; + } - var defaultName = Environment.UserName; - if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) - { - defaultName = "MyJellyfinUser"; - } + var defaultName = Environment.UserName; + if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) + { + defaultName = "MyJellyfinUser"; + } - _logger.LogWarning("No users, creating one with username {UserName}", defaultName); + _logger.LogWarning("No users, creating one with username {UserName}", defaultName); - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); newUser.SetPermission(PermissionKind.IsAdministrator, true); newUser.SetPermission(PermissionKind.EnableContentDeletion, true); @@ -595,12 +604,9 @@ namespace Jellyfin.Server.Implementations.Users var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .FirstOrDefault(u => u.Id.Equals(userId)) + var user = await GetUsersInternal(dbContext) + .FirstOrDefaultAsync(u => u.Id.Equals(userId)) + .ConfigureAwait(false) ?? throw new ArgumentException("No user exists with given Id!"); user.SubtitleMode = config.SubtitleMode; @@ -628,7 +634,6 @@ namespace Jellyfin.Server.Implementations.Users user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); dbContext.Update(user); - _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } } @@ -639,12 +644,9 @@ namespace Jellyfin.Server.Implementations.Users var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .FirstOrDefault(u => u.Id.Equals(userId)) + var user = await GetUsersInternal(dbContext) + .FirstOrDefaultAsync(u => u.Id.Equals(userId)) + .ConfigureAwait(false) ?? throw new ArgumentException("No user exists with given Id!"); // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" @@ -704,7 +706,6 @@ namespace Jellyfin.Server.Implementations.Users user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); dbContext.Update(user); - _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } } @@ -725,7 +726,6 @@ namespace Jellyfin.Server.Implementations.Users } user.ProfileImage = null; - _users[user.Id] = user; } internal static void ThrowIfInvalidUsername(string name) @@ -872,8 +872,15 @@ namespace Jellyfin.Server.Implementations.Users private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) { dbContext.Users.Update(user); - _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } + + private IQueryable GetUsersInternal(JellyfinDbContext dbContext) + => dbContext.Users + .AsSplitQuery() + .Include(user => user.Permissions) + .Include(user => user.Preferences) + .Include(user => user.AccessSchedules) + .Include(user => user.ProfileImage); } } diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index c12c90a689..93277a981e 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -14,6 +14,7 @@ using Jellyfin.Server.Implementations.Security; using Jellyfin.Server.Implementations.Trickplay; using Jellyfin.Server.Implementations.Users; using MediaBrowser.Controller; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.BaseItemManager; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Drawing; @@ -78,6 +79,9 @@ namespace Jellyfin.Server serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddScoped(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); -- cgit v1.2.3 From 615089228a9c456f480d328aa3eb11017a3cb857 Mon Sep 17 00:00:00 2001 From: Dave Senn Date: Sat, 6 Jan 2024 21:44:10 +0100 Subject: Add nuget.config file to fix NU1507 by only allowing nuget.org as package source. see: https://learn.microsoft.com/en-us/nuget/consume-packages/package-source-mapping and https://github.com/dotnet/sdk/issues/25379 (#10396) --- nuget.config | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 nuget.config diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000000..54e660f9c9 --- /dev/null +++ b/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file -- cgit v1.2.3 From 7b5c41c2a521f2e49f8acc24f29c8472938f3987 Mon Sep 17 00:00:00 2001 From: TelepathicWalrus <48514138+TelepathicWalrus@users.noreply.github.com> Date: Sat, 6 Jan 2024 20:59:56 +0000 Subject: Add option to use replaygain tags for audio (#10566) * Add option to use replaygain tags for audio * Change regex to be specific * Use ffprobe for faster metadata grabs * Change regex to .Match --- MediaBrowser.Model/Configuration/LibraryOptions.cs | 2 + .../MediaInfo/AudioFileProber.cs | 50 +++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index fbad29143b..1c071067df 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs @@ -31,6 +31,8 @@ namespace MediaBrowser.Model.Configuration public bool EnableLUFSScan { get; set; } + public bool UseReplayGainTags { get; set; } + public bool EnableChapterImageExtraction { get; set; } public bool ExtractChapterImagesDuringLibraryScan { get; set; } diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index d817042274..f718325df5 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -61,6 +61,9 @@ namespace MediaBrowser.Providers.MediaInfo [GeneratedRegex(@"I:\s+(.*?)\s+LUFS")] private static partial Regex LUFSRegex(); + [GeneratedRegex(@"REPLAYGAIN_TRACK_GAIN:\s+-?([0-9.]+)\s+dB")] + private static partial Regex ReplayGainTagRegex(); + /// /// Probes the specified item for metadata. /// @@ -104,8 +107,50 @@ namespace MediaBrowser.Providers.MediaInfo } var libraryOptions = _libraryManager.GetLibraryOptions(item); + bool foundLUFSValue = false; + + if (libraryOptions.UseReplayGainTags) + { + using (var process = new Process() + { + StartInfo = new ProcessStartInfo + { + FileName = _mediaEncoder.ProbePath, + Arguments = $"-hide_banner -i \"{path}\"", + RedirectStandardOutput = false, + RedirectStandardError = true + }, + }) + { + try + { + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting ffmpeg"); + + throw; + } + + using var reader = process.StandardError; + var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + Match split = ReplayGainTagRegex().Match(output); + + if (split.Success) + { + item.LUFS = DefaultLUFSValue - float.Parse(split.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); + foundLUFSValue = true; + } + else + { + item.LUFS = DefaultLUFSValue; + } + } + } - if (libraryOptions.EnableLUFSScan) + if (libraryOptions.EnableLUFSScan && !foundLUFSValue) { using (var process = new Process() { @@ -144,7 +189,8 @@ namespace MediaBrowser.Providers.MediaInfo } } } - else + + if (!libraryOptions.EnableLUFSScan && !libraryOptions.UseReplayGainTags) { item.LUFS = DefaultLUFSValue; } -- cgit v1.2.3 From f49de51225b2206609df6a89f3cbb5fd7459ff68 Mon Sep 17 00:00:00 2001 From: Mohamed Akram Date: Sun, 7 Jan 2024 01:11:08 +0400 Subject: Detect audio spatial format (#9996) * Detect audio spatial format * Update MediaBrowser.Model/Entities/MediaStream.cs * Update MediaStream.cs --------- Co-authored-by: Cody Robibero --- CONTRIBUTORS.md | 1 + Jellyfin.Data/Enums/AudioSpatialFormat.cs | 22 ++++++ MediaBrowser.Model/Entities/MediaStream.cs | 22 ++++++ .../Probing/ProbeResultNormalizerTests.cs | 10 ++- .../Test Data/Probing/video_metadata.json | 88 +++++++++++++++++++++- 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 Jellyfin.Data/Enums/AudioSpatialFormat.cs diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index d208879d17..4e45fd24ad 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -81,6 +81,7 @@ - [Maxr1998](https://github.com/Maxr1998) - [mcarlton00](https://github.com/mcarlton00) - [mitchfizz05](https://github.com/mitchfizz05) + - [mohd-akram](https://github.com/mohd-akram) - [MrTimscampi](https://github.com/MrTimscampi) - [n8225](https://github.com/n8225) - [Nalsai](https://github.com/Nalsai) diff --git a/Jellyfin.Data/Enums/AudioSpatialFormat.cs b/Jellyfin.Data/Enums/AudioSpatialFormat.cs new file mode 100644 index 0000000000..5e3a123320 --- /dev/null +++ b/Jellyfin.Data/Enums/AudioSpatialFormat.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Data.Enums; + +/// +/// An enum representing formats of spatial audio. +/// +public enum AudioSpatialFormat +{ + /// + /// None audio spatial format. + /// + None, + + /// + /// Dolby Atmos audio spatial format. + /// + DolbyAtmos, + + /// + /// DTS:X audio spatial format. + /// + DTSX, +} diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index 34642b83aa..ae4a008bb3 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Globalization; using System.Linq; using System.Text; @@ -214,6 +215,27 @@ namespace MediaBrowser.Model.Entities } } + /// + /// Gets the audio spatial format. + /// + /// The audio spatial format. + [DefaultValue(AudioSpatialFormat.None)] + public AudioSpatialFormat AudioSpatialFormat + { + get + { + if (Type != MediaStreamType.Audio || string.IsNullOrEmpty(Profile)) + { + return AudioSpatialFormat.None; + } + + return + Profile.Contains("Dolby Atmos", StringComparison.OrdinalIgnoreCase) ? AudioSpatialFormat.DolbyAtmos : + Profile.Contains("DTS:X", StringComparison.OrdinalIgnoreCase) ? AudioSpatialFormat.DTSX : + AudioSpatialFormat.None; + } + } + public string LocalizedUndefined { get; set; } public string LocalizedDefault { get; set; } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index 198dc63efe..344ac8971c 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -46,7 +46,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing var internalMediaInfoResult = JsonSerializer.Deserialize(bytes, _jsonOptions); MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.mkv", MediaProtocol.File); - Assert.Single(res.MediaStreams); + Assert.Equal(3, res.MediaStreams.Count); Assert.NotNull(res.VideoStream); Assert.Equal("4:3", res.VideoStream.AspectRatio); @@ -83,6 +83,14 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Equal(1, res.VideoStream.BlPresentFlag); Assert.Equal(0, res.VideoStream.DvBlSignalCompatibilityId); + var audio1 = res.MediaStreams[1]; + Assert.Equal("eac3", audio1.Codec); + Assert.Equal(AudioSpatialFormat.DolbyAtmos, audio1.AudioSpatialFormat); + + var audio2 = res.MediaStreams[2]; + Assert.Equal("dts", audio2.Codec); + Assert.Equal(AudioSpatialFormat.DTSX, audio2.AudioSpatialFormat); + Assert.Empty(res.Chapters); Assert.Equal("Just color bars", res.Overview); } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_metadata.json index 519d81179c..a49c686900 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_metadata.json +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_metadata.json @@ -61,6 +61,92 @@ "dv_bl_signal_compatibility_id": 0 } ] + }, + { + "index": 1, + "codec_name": "eac3", + "codec_long_name": "ATSC A/52B (AC-3, E-AC-3)", + "profile": "Dolby Digital Plus + Dolby Atmos", + "codec_type": "audio", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 6, + "channel_layout": "5.1(side)", + "bits_per_sample": 0, + "initial_padding": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "bit_rate": "640000", + "disposition": { + "default": 1, + "dub": 0, + "original": 1, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "eng" + } + }, + { + "index": 2, + "codec_name": "dts", + "codec_long_name": "DCA (DTS Coherent Acoustics)", + "profile": "DTS-HD MA + DTS:X", + "codec_type": "audio", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "s32p", + "sample_rate": "48000", + "channels": 8, + "channel_layout": "7.1", + "bits_per_sample": 0, + "initial_padding": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "bits_per_raw_sample": "24", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "eng" + } } ], "chapters": [ @@ -68,7 +154,7 @@ ], "format": { "filename": "some_metadata.mkv", - "nb_streams": 1, + "nb_streams": 3, "nb_programs": 0, "format_name": "matroska,webm", "format_long_name": "Matroska / WebM", -- cgit v1.2.3 From adb0837dc219df6f9474d819c68a73f8c3decf48 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Sat, 6 Jan 2024 14:43:19 -0700 Subject: Respond with Service Unavailable if not in correct network --- Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs | 2 ++ Jellyfin.Api/Middleware/LanFilteringMiddleware.cs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs index 27bcd5570c..0d107d11b3 100644 --- a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs +++ b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs @@ -41,6 +41,8 @@ public class IPBasedAccessValidationMiddleware if (!networkManager.HasRemoteAccess(remoteIP)) { + // No access from network, respond with 503 instead of 200. + httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; return; } diff --git a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs index d8c95ddffe..34ed4818f9 100644 --- a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs +++ b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Threading.Tasks; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; @@ -40,6 +41,8 @@ public class LanFilteringMiddleware var host = httpContext.GetNormalizedRemoteIP(); if (!networkManager.IsInLocalNetwork(host)) { + // No access from network, respond with 503 instead of 200. + httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; return; } -- cgit v1.2.3 From f2b01f66e88d22f11c6530c085eda002ceb175dd Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Sat, 6 Jan 2024 15:01:50 -0700 Subject: Specify DateTimeKind when pulling a DateTime out of the database --- Emby.Server.Implementations/Data/SqliteExtensions.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Emby.Server.Implementations/Data/SqliteExtensions.cs b/Emby.Server.Implementations/Data/SqliteExtensions.cs index 01b5fdaeea..25ef57d271 100644 --- a/Emby.Server.Implementations/Data/SqliteExtensions.cs +++ b/Emby.Server.Implementations/Data/SqliteExtensions.cs @@ -104,6 +104,13 @@ namespace Emby.Server.Implementations.Data if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AdjustToUniversal, out var dateTimeResult)) { + // If the resulting DateTimeKind is Unspecified it is actually Utc. + // This is required downstream for the Json serializer. + if (dateTimeResult.Kind == DateTimeKind.Unspecified) + { + dateTimeResult = DateTime.SpecifyKind(dateTimeResult, DateTimeKind.Utc); + } + result = dateTimeResult; return true; } -- cgit v1.2.3 From fa0413d6e4ec565fd039a99ead3b73391046f222 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Sat, 6 Jan 2024 15:24:17 -0700 Subject: Use StatusCodes which is already an int --- Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs | 2 +- Jellyfin.Api/Middleware/LanFilteringMiddleware.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs index 0d107d11b3..842a69dd98 100644 --- a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs +++ b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs @@ -42,7 +42,7 @@ public class IPBasedAccessValidationMiddleware if (!networkManager.HasRemoteAccess(remoteIP)) { // No access from network, respond with 503 instead of 200. - httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; return; } diff --git a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs index 34ed4818f9..35b0a1dd06 100644 --- a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs +++ b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs @@ -42,7 +42,7 @@ public class LanFilteringMiddleware if (!networkManager.IsInLocalNetwork(host)) { // No access from network, respond with 503 instead of 200. - httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; return; } -- cgit v1.2.3 From fa03ed4f56b6738ffe37a972997c7d84b7483fc1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:11:08 -0700 Subject: chore(deps): update github/codeql-action action to v3.23.0 (#10832) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci-codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 7c07335980..4b2673e82d 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@012739e5082ff0c22ca6d6ab32e07c36df03c4a4 # v3.22.12 + uses: github/codeql-action/init@e5f05b81d5b6ff8cfa111c80c22c5fd02a384118 # v3.23.0 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@012739e5082ff0c22ca6d6ab32e07c36df03c4a4 # v3.22.12 + uses: github/codeql-action/autobuild@e5f05b81d5b6ff8cfa111c80c22c5fd02a384118 # v3.23.0 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@012739e5082ff0c22ca6d6ab32e07c36df03c4a4 # v3.22.12 + uses: github/codeql-action/analyze@e5f05b81d5b6ff8cfa111c80c22c5fd02a384118 # v3.23.0 -- cgit v1.2.3 From 08d773d119331abff5eeb1cf6669f8bd6f8c8126 Mon Sep 17 00:00:00 2001 From: Kent Date: Sun, 7 Jan 2024 16:49:12 +0000 Subject: Translated using Weblate (Norwegian Nynorsk) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nn/ --- Emby.Server.Implementations/Localization/Core/nn.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/nn.json b/Emby.Server.Implementations/Localization/Core/nn.json index 32d4f3a8b6..d0c914de37 100644 --- a/Emby.Server.Implementations/Localization/Core/nn.json +++ b/Emby.Server.Implementations/Localization/Core/nn.json @@ -117,5 +117,6 @@ "TaskCleanActivityLog": "Slett aktivitetslogg", "Undefined": "Udefinert", "Forced": "Tvungen", - "Default": "Standard" + "Default": "Standard", + "External": "Ekstern" } -- cgit v1.2.3 From a152b71b25be6cf22a767634e3b475a4200634ad Mon Sep 17 00:00:00 2001 From: Andrejs Date: Sat, 6 Jan 2024 18:29:29 +0000 Subject: Translated using Weblate (Latvian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lv/ --- Emby.Server.Implementations/Localization/Core/lv.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 82a071309e..6e58ef8345 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -123,5 +123,7 @@ "External": "Ārējais", "HearingImpaired": "Ar dzirdes traucējumiem", "TaskKeyframeExtractor": "Atslēgkadru ekstraktors", - "TaskKeyframeExtractorDescription": "Ekstraktē atslēgkadrus no video failiem lai izveidotu precīzākus HLS atskaņošanas sarakstus. Šis process var būt ilgs." + "TaskKeyframeExtractorDescription": "Ekstraktē atslēgkadrus no video failiem lai izveidotu precīzākus HLS atskaņošanas sarakstus. Šis process var būt ilgs.", + "TaskRefreshTrickplayImages": "Ģenerēt partīšanas attēlus", + "TaskRefreshTrickplayImagesDescription": "Izveido priekšskatījumus videoklipu pārtīšanai iespējotajās bibliotēkās." } -- cgit v1.2.3 From 2f1b7d0988520d9341692f235b8adf1e4e10ebd7 Mon Sep 17 00:00:00 2001 From: Vasyl Belynets Date: Sun, 7 Jan 2024 21:05:03 +0000 Subject: Translated using Weblate (Ukrainian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/uk/ --- Emby.Server.Implementations/Localization/Core/uk.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 4c3bf2f10b..6f0dcfbe30 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -18,7 +18,7 @@ "HeaderContinueWatching": "Продовжити перегляд", "HeaderAlbumArtists": "Виконавці альбому", "Genres": "Жанри", - "Folders": "Каталоги", + "Folders": "Теки", "Favorites": "Обрані", "DeviceOnlineWithName": "Пристрій {0} підключився", "DeviceOfflineWithName": "Пристрій {0} відключився", @@ -83,7 +83,7 @@ "SubtitleDownloadFailureFromForItem": "Не вдалося завантажити субтитри з {0} для {1}", "StartupEmbyServerIsLoading": "Jellyfin Server завантажується. Будь ласка, спробуйте трішки пізніше.", "Songs": "Пісні", - "Shows": "Шоу", + "Shows": "Телепередачі", "ServerNameNeedsToBeRestarted": "{0} потрібно перезапустити", "ScheduledTaskStartedWithName": "{0} розпочато", "ScheduledTaskFailedWithName": "{0} незавершено, збій", -- cgit v1.2.3 From 051fa04a803068b5fe8c86e1f991e85a9fbc4d04 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 28 Dec 2023 14:34:44 -0500 Subject: Move GetRecordingStreamMediaSources to IMediaSourceManager --- .../Library/MediaSourceManager.cs | 35 +++++++++++++++++++++- .../LiveTv/EmbyTV/EmbyTV.cs | 32 -------------------- .../LiveTv/LiveTvMediaSourceProvider.cs | 2 +- .../Library/IMediaSourceManager.cs | 9 ++++++ 4 files changed, 44 insertions(+), 34 deletions(-) diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 96fad9bca8..68eccf311d 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -11,14 +11,15 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using EasyCaching.Core.Configurations; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; @@ -37,6 +38,7 @@ namespace Emby.Server.Implementations.Library // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message. private const char LiveStreamIdDelimeter = '_'; + private readonly IServerApplicationHost _appHost; private readonly IItemRepository _itemRepo; private readonly IUserManager _userManager; private readonly ILibraryManager _libraryManager; @@ -55,6 +57,7 @@ namespace Emby.Server.Implementations.Library private IMediaSourceProvider[] _providers; public MediaSourceManager( + IServerApplicationHost appHost, IItemRepository itemRepo, IApplicationPaths applicationPaths, ILocalizationManager localizationManager, @@ -66,6 +69,7 @@ namespace Emby.Server.Implementations.Library IMediaEncoder mediaEncoder, IDirectoryService directoryService) { + _appHost = appHost; _itemRepo = itemRepo; _userManager = userManager; _libraryManager = libraryManager; @@ -799,6 +803,35 @@ namespace Emby.Server.Implementations.Library return result.Item1; } + public async Task> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken) + { + var stream = new MediaSourceInfo + { + EncoderPath = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveRecordings/" + info.Id + "/stream", + EncoderProtocol = MediaProtocol.Http, + Path = info.Path, + Protocol = MediaProtocol.File, + Id = info.Id, + SupportsDirectPlay = false, + SupportsDirectStream = true, + SupportsTranscoding = true, + IsInfiniteStream = true, + RequiresOpening = false, + RequiresClosing = false, + BufferMs = 0, + IgnoreDts = true, + IgnoreIndex = true + }; + + await new LiveStreamHelper(_mediaEncoder, _logger, _appPaths) + .AddMediaInfoWithProbe(stream, false, false, cancellationToken).ConfigureAwait(false); + + return new List + { + stream + }; + } + public async Task CloseLiveStream(string id) { ArgumentException.ThrowIfNullOrEmpty(id); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index abe3ff349f..e2e0dfb2bb 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -47,7 +47,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private const int TunerDiscoveryDurationMs = 3000; - private readonly IServerApplicationHost _appHost; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; @@ -76,7 +75,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private bool _disposed; public EmbyTV( - IServerApplicationHost appHost, IStreamHelper streamHelper, IMediaSourceManager mediaSourceManager, ILogger logger, @@ -91,7 +89,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { Current = this; - _appHost = appHost; _logger = logger; _httpClientFactory = httpClientFactory; _config = config; @@ -1021,35 +1018,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV throw new NotImplementedException(); } - public async Task> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken) - { - var stream = new MediaSourceInfo - { - EncoderPath = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveRecordings/" + info.Id + "/stream", - EncoderProtocol = MediaProtocol.Http, - Path = info.Path, - Protocol = MediaProtocol.File, - Id = info.Id, - SupportsDirectPlay = false, - SupportsDirectStream = true, - SupportsTranscoding = true, - IsInfiniteStream = true, - RequiresOpening = false, - RequiresClosing = false, - BufferMs = 0, - IgnoreDts = true, - IgnoreIndex = true - }; - - await new LiveStreamHelper(_mediaEncoder, _logger, _config.CommonApplicationPaths) - .AddMediaInfoWithProbe(stream, false, false, cancellationToken).ConfigureAwait(false); - - return new List - { - stream - }; - } - public Task CloseLiveStream(string id, CancellationToken cancellationToken) { return Task.CompletedTask; diff --git a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs index 6a92fc5997..a5a1a4a4c5 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs @@ -61,7 +61,7 @@ namespace Emby.Server.Implementations.LiveTv { if (activeRecordingInfo is not null) { - sources = await EmbyTV.EmbyTV.Current.GetRecordingStreamMediaSources(activeRecordingInfo, cancellationToken) + sources = await _mediaSourceManager.GetRecordingStreamMediaSources(activeRecordingInfo, cancellationToken) .ConfigureAwait(false); } else diff --git a/MediaBrowser.Controller/Library/IMediaSourceManager.cs b/MediaBrowser.Controller/Library/IMediaSourceManager.cs index f1758a9d80..bace703ada 100644 --- a/MediaBrowser.Controller/Library/IMediaSourceManager.cs +++ b/MediaBrowser.Controller/Library/IMediaSourceManager.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -116,6 +117,14 @@ namespace MediaBrowser.Controller.Library /// An instance of . public ILiveStream GetLiveStreamInfoByUniqueId(string uniqueId); + /// + /// Gets the media sources for an active recording. + /// + /// The . + /// The . + /// A task containing the 's for the recording. + Task> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken); + /// /// Closes the media source. /// -- cgit v1.2.3 From 7eba162879f6d1ff04539cac5c0d6372a955d82b Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 28 Dec 2023 14:49:35 -0500 Subject: Move LiveTv tests to separate project --- .../Properties/AssemblyInfo.cs | 1 + Jellyfin.sln | 7 + tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs | 156 ++++++++++ .../Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs | 326 +++++++++++++++++++++ .../Jellyfin.LiveTv.Tests.csproj | 29 ++ .../Listings/XmlTvListingsProviderTests.cs | 89 ++++++ .../Jellyfin.LiveTv.Tests/RecordingHelperTests.cs | 109 +++++++ .../SchedulesDirectDeserializeTests.cs | 240 +++++++++++++++ .../Test Data/LiveTv/10.10.10.100/discover.json | 1 + .../Test Data/LiveTv/10.10.10.100/lineup.json | 1 + .../Test Data/LiveTv/192.168.1.182/discover.json | 1 + .../Test Data/LiveTv/192.168.1.182/lineup.json | 1 + .../LiveTv/Listings/XmlTv/emptycategory.xml | 6 + .../Test Data/LiveTv/Listings/XmlTv/notitle.xml | 10 + .../SchedulesDirect/headends_response.json | 1 + .../Test Data/SchedulesDirect/lineup_response.json | 1 + .../SchedulesDirect/lineups_response.json | 1 + .../metadata_programs_response.json | 1 + .../SchedulesDirect/programs_response.json | 1 + .../SchedulesDirect/schedules_request.json | 1 + .../SchedulesDirect/schedules_response.json | 1 + .../SchedulesDirect/token_live_response.json | 1 + .../SchedulesDirect/token_offline_response.json | 1 + .../LiveTv/HdHomerunHostTests.cs | 156 ---------- .../LiveTv/HdHomerunManagerTests.cs | 326 --------------------- .../LiveTv/Listings/XmlTvListingsProviderTests.cs | 89 ------ .../LiveTv/RecordingHelperTests.cs | 109 ------- .../SchedulesDirectDeserializeTests.cs | 240 --------------- .../Test Data/LiveTv/10.10.10.100/discover.json | 1 - .../Test Data/LiveTv/10.10.10.100/lineup.json | 1 - .../Test Data/LiveTv/192.168.1.182/discover.json | 1 - .../Test Data/LiveTv/192.168.1.182/lineup.json | 1 - .../LiveTv/Listings/XmlTv/emptycategory.xml | 6 - .../Test Data/LiveTv/Listings/XmlTv/notitle.xml | 10 - .../SchedulesDirect/headends_response.json | 1 - .../Test Data/SchedulesDirect/lineup_response.json | 1 - .../SchedulesDirect/lineups_response.json | 1 - .../metadata_programs_response.json | 1 - .../SchedulesDirect/programs_response.json | 1 - .../SchedulesDirect/schedules_request.json | 1 - .../SchedulesDirect/schedules_response.json | 1 - .../SchedulesDirect/token_live_response.json | 1 - .../SchedulesDirect/token_offline_response.json | 1 - 43 files changed, 986 insertions(+), 949 deletions(-) create mode 100644 tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj create mode 100644 tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/discover.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/lineup.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/discover.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/lineup.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/headends_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineup_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineups_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/programs_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_request.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_live_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_offline_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunManagerTests.cs delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/discover.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/lineup.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/discover.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/lineup.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/headends_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineup_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineups_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/metadata_programs_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/programs_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_request.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_live_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_offline_response.json diff --git a/Emby.Server.Implementations/Properties/AssemblyInfo.cs b/Emby.Server.Implementations/Properties/AssemblyInfo.cs index 41c396ac1d..fb7377b1df 100644 --- a/Emby.Server.Implementations/Properties/AssemblyInfo.cs +++ b/Emby.Server.Implementations/Properties/AssemblyInfo.cs @@ -17,6 +17,7 @@ using System.Runtime.InteropServices; [assembly: NeutralResourcesLanguage("en")] [assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")] [assembly: InternalsVisibleTo("Emby.Server.Implementations.Fuzz")] +[assembly: InternalsVisibleTo("Jellyfin.LiveTv.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/Jellyfin.sln b/Jellyfin.sln index 4385ac2417..31e302d944 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -87,6 +87,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.MediaEncoding.Hls. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.MediaEncoding.Keyframes.Tests", "tests\Jellyfin.MediaEncoding.Keyframes.Tests\Jellyfin.MediaEncoding.Keyframes.Tests.csproj", "{24960660-DE6C-47BF-AEEF-CEE8F19FE6C2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv.Tests", "tests\Jellyfin.LiveTv.Tests\Jellyfin.LiveTv.Tests.csproj", "{C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -233,6 +235,10 @@ Global {24960660-DE6C-47BF-AEEF-CEE8F19FE6C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {24960660-DE6C-47BF-AEEF-CEE8F19FE6C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {24960660-DE6C-47BF-AEEF-CEE8F19FE6C2}.Release|Any CPU.Build.0 = Release|Any CPU + {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -259,6 +265,7 @@ Global {08FFF49B-F175-4807-A2B5-73B0EBD9F716} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {154872D9-6C12-4007-96E3-8F70A58386CE} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {0A3FCC4D-C714-4072-B90F-E374A15F9FF9} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} + {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE} diff --git a/tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs b/tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs new file mode 100644 index 0000000000..bc4b2da5b9 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs @@ -0,0 +1,156 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun; +using MediaBrowser.Model.LiveTv; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Jellyfin.LiveTv.Tests +{ + public class HdHomerunHostTests + { + private readonly Fixture _fixture; + private readonly HdHomerunHost _hdHomerunHost; + + public HdHomerunHostTests() + { + var messageHandler = new Mock(); + messageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns( + (m, _) => + { + return Task.FromResult(new HttpResponseMessage() + { + Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv", m.RequestUri!.Host, m.RequestUri.Segments[^1]))) + }); + }); + + var http = new Mock(); + http.Setup(x => x.CreateClient(It.IsAny())) + .Returns(new HttpClient(messageHandler.Object)); + _fixture = new Fixture(); + _fixture.Customize(new AutoMoqCustomization + { + ConfigureMembers = true + }).Inject(http); + _hdHomerunHost = _fixture.Create(); + } + + [Fact] + public async Task GetModelInfo_Valid_Success() + { + var host = new TunerHostInfo() + { + Url = "192.168.1.182" + }; + + var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None); + Assert.Equal("HDHomeRun PRIME", modelInfo.FriendlyName); + Assert.Equal("HDHR3-CC", modelInfo.ModelNumber); + Assert.Equal("hdhomerun3_cablecard", modelInfo.FirmwareName); + Assert.Equal("20160630atest2", modelInfo.FirmwareVersion); + Assert.Equal("FFFFFFFF", modelInfo.DeviceID); + Assert.Equal("FFFFFFFF", modelInfo.DeviceAuth); + Assert.Equal(3, modelInfo.TunerCount); + Assert.Equal("http://192.168.1.182:80", modelInfo.BaseURL); + Assert.Equal("http://192.168.1.182:80/lineup.json", modelInfo.LineupURL); + } + + [Fact] + public async Task GetModelInfo_Legacy_Success() + { + var host = new TunerHostInfo() + { + Url = "10.10.10.100" + }; + + var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None); + Assert.Equal("HDHomeRun DUAL", modelInfo.FriendlyName); + Assert.Equal("HDHR3-US", modelInfo.ModelNumber); + Assert.Equal("hdhomerun3_atsc", modelInfo.FirmwareName); + Assert.Equal("20200225", modelInfo.FirmwareVersion); + Assert.Equal("10xxxxx5", modelInfo.DeviceID); + Assert.Null(modelInfo.DeviceAuth); + Assert.Equal(2, modelInfo.TunerCount); + Assert.Equal("http://10.10.10.100:80", modelInfo.BaseURL); + Assert.Null(modelInfo.LineupURL); + } + + [Fact] + public async Task GetModelInfo_EmptyUrl_ArgumentException() + { + var host = new TunerHostInfo() + { + Url = string.Empty + }; + + await Assert.ThrowsAsync(() => _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None)); + } + + [Fact] + public async Task GetLineup_Valid_Success() + { + var host = new TunerHostInfo() + { + Url = "192.168.1.182" + }; + + var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None); + Assert.Equal(6, channels.Count); + Assert.Equal("4.1", channels[0].GuideNumber); + Assert.Equal("WCMH-DT", channels[0].GuideName); + Assert.True(channels[0].HD); + Assert.True(channels[0].Favorite); + Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); + } + + [Fact] + public async Task GetLineup_Legacy_Success() + { + var host = new TunerHostInfo() + { + Url = "10.10.10.100" + }; + + // Placeholder json is invalid, just need to make sure we can reach it + await Assert.ThrowsAsync(() => _hdHomerunHost.GetLineup(host, CancellationToken.None)); + } + + [Fact] + public async Task GetLineup_ImportFavoritesOnly_Success() + { + var host = new TunerHostInfo() + { + Url = "192.168.1.182", + ImportFavoritesOnly = true + }; + + var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None); + Assert.Single(channels); + Assert.Equal("4.1", channels[0].GuideNumber); + Assert.Equal("WCMH-DT", channels[0].GuideName); + Assert.True(channels[0].HD); + Assert.True(channels[0].Favorite); + Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); + } + + [Fact] + public async Task TryGetTunerHostInfo_Valid_Success() + { + var host = await _hdHomerunHost.TryGetTunerHostInfo("192.168.1.182", CancellationToken.None); + Assert.Equal(_hdHomerunHost.Type, host.Type); + Assert.Equal("192.168.1.182", host.Url); + Assert.Equal("HDHomeRun PRIME", host.FriendlyName); + Assert.Equal("FFFFFFFF", host.DeviceId); + Assert.Equal(3, host.TunerCount); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs b/tests/Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs new file mode 100644 index 0000000000..fcebc0ff75 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs @@ -0,0 +1,326 @@ +using System; +using System.Text; +using Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun; +using Xunit; + +namespace Jellyfin.LiveTv.Tests +{ + public class HdHomerunManagerTests + { + [Fact] + public void WriteNullTerminatedString_Empty_Success() + { + ReadOnlySpan expected = stackalloc byte[] + { + 1, 0 + }; + + Span buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteNullTerminatedString(buffer, string.Empty); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteNullTerminatedString_Valid_Success() + { + ReadOnlySpan expected = stackalloc byte[] + { + 10, (byte)'T', (byte)'h', (byte)'e', (byte)' ', (byte)'q', (byte)'u', (byte)'i', (byte)'c', (byte)'k', 0 + }; + + Span buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteNullTerminatedString(buffer, "The quick"); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteGetMessage_Valid_Success() + { + ReadOnlySpan expected = stackalloc byte[] + { + 0, 4, + 0, 12, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 0xc0, 0xc9, 0x87, 0x33 + }; + + Span buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteGetMessage(buffer, 0, "N"); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteSetMessage_NoLockKey_Success() + { + ReadOnlySpan expected = stackalloc byte[] + { + 0, 4, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xa9, 0x49, 0xd0, 0x68 + }; + + Span buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteSetMessage(buffer, 0, "N", "value", null); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteSetMessage_LockKey_Success() + { + ReadOnlySpan expected = stackalloc byte[] + { + 0, 4, + 0, 26, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 21, + 4, 0x00, 0x01, 0x38, 0xd5, + 0x8e, 0xb6, 0x06, 0x82 + }; + + Span buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteSetMessage(buffer, 0, "N", "value", 80085); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void TryGetReturnValueOfGetSet_Valid_Success() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.True(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out var value)); + Assert.Equal("value", Encoding.UTF8.GetString(value)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidCrc_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf4 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidPacketType_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 4, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xa9, 0x49, 0xd0, 0x68 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidPacket_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 0x7d, 0xa3, 0xa3 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooSmallMessageLength_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 19, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x25, 0x25, 0x44, 0x9a + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooLargeMessageLength_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 21, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xe3, 0x20, 0x79, 0x6c + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooLargeNameLength_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 20, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xe1, 0x8e, 0x9c, 0x74 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidGetSetNameTag_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 4, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xee, 0x05, 0xe7, 0x12 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidGetSetValueTag_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 3, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x64, 0xaa, 0x66, 0xf9 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooLargeValueLength_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 7, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xc9, 0xa8, 0xd4, 0x55 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void VerifyReturnValueOfGetSet_Valid_True() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.True(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "value")); + } + + [Fact] + public void VerifyReturnValueOfGetSet_WrongValue_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.False(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "none")); + } + + [Fact] + public void VerifyReturnValueOfGetSet_InvalidPacket_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 4, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.False(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "value")); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj b/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj new file mode 100644 index 0000000000..de448bada6 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj @@ -0,0 +1,29 @@ + + + net8.0 + + + + + PreserveNewest + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs new file mode 100644 index 0000000000..71d135735d --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.LiveTv.Listings; +using MediaBrowser.Model.LiveTv; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.Listings; + +public class XmlTvListingsProviderTests +{ + private readonly Fixture _fixture; + private readonly XmlTvListingsProvider _xmlTvListingsProvider; + + public XmlTvListingsProviderTests() + { + var messageHandler = new Mock(); + messageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns( + (m, _) => + { + return Task.FromResult(new HttpResponseMessage() + { + Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv/Listings/XmlTv", m.RequestUri!.Segments[^1]))) + }); + }); + + var http = new Mock(); + http.Setup(x => x.CreateClient(It.IsAny())) + .Returns(new HttpClient(messageHandler.Object)); + _fixture = new Fixture(); + _fixture.Customize(new AutoMoqCustomization + { + ConfigureMembers = true + }).Inject(http); + _xmlTvListingsProvider = _fixture.Create(); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/notitle.xml")] + [InlineData("https://example.com/notitle.xml")] + public async Task GetProgramsAsync_NoTitle_Success(string path) + { + var info = new ListingsProviderInfo() + { + Path = path + }; + + var startDate = new DateTime(2022, 11, 4); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + var programsList = programs.ToList(); + Assert.Single(programsList); + var program = programsList[0]; + Assert.Null(program.Name); + Assert.Null(program.SeriesId); + Assert.Null(program.EpisodeTitle); + Assert.True(program.IsSports); + Assert.True(program.HasImage); + Assert.Equal("https://domain.tld/image.png", program.ImageUrl); + Assert.Equal("3297", program.ChannelId); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/emptycategory.xml")] + [InlineData("https://example.com/emptycategory.xml")] + public async Task GetProgramsAsync_EmptyCategories_Success(string path) + { + var info = new ListingsProviderInfo() + { + Path = path + }; + + var startDate = new DateTime(2022, 11, 4); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + var programsList = programs.ToList(); + Assert.Single(programsList); + var program = programsList[0]; + Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g)); + Assert.Equal("3297", program.ChannelId); + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs new file mode 100644 index 0000000000..a5941e7f08 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs @@ -0,0 +1,109 @@ +using System; +using Emby.Server.Implementations.LiveTv.EmbyTV; +using MediaBrowser.Controller.LiveTv; +using Xunit; + +namespace Jellyfin.LiveTv.Tests +{ + public static class RecordingHelperTests + { + public static TheoryData GetRecordingName_Success_TestData() + { + var data = new TheoryData(); + + data.Add( + "The Incredibles 2020_04_20_21_06_00", + new TimerInfo + { + Name = "The Incredibles", + StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), + IsMovie = true + }); + + data.Add( + "The Incredibles (2004)", + new TimerInfo + { + Name = "The Incredibles", + IsMovie = true, + ProductionYear = 2004 + }); + data.Add( + "The Big Bang Theory 2020_04_20_21_06_00", + new TimerInfo + { + Name = "The Big Bang Theory", + StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), + IsProgramSeries = true, + }); + data.Add( + "The Big Bang Theory S12E10", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + SeasonNumber = 12, + EpisodeNumber = 10 + }); + data.Add( + "The Big Bang Theory S12E10 The VCR Illumination", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + SeasonNumber = 12, + EpisodeNumber = 10, + EpisodeTitle = "The VCR Illumination" + }); + data.Add( + "The Big Bang Theory 2018-12-06", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + OriginalAirDate = new DateTime(2018, 12, 6, 0, 0, 0, DateTimeKind.Local) + }); + + data.Add( + "The Big Bang Theory 2018-12-06 - The VCR Illumination", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + OriginalAirDate = new DateTime(2018, 12, 6, 0, 0, 0, DateTimeKind.Local), + EpisodeTitle = "The VCR Illumination" + }); + + data.Add( + "The Big Bang Theory 2018_12_06_21_06_00 - The VCR Illumination", + new TimerInfo + { + Name = "The Big Bang Theory", + StartDate = new DateTime(2018, 12, 6, 21, 6, 0, DateTimeKind.Local), + IsProgramSeries = true, + OriginalAirDate = new DateTime(2018, 12, 6), + EpisodeTitle = "The VCR Illumination" + }); + + data.Add( + "Lorem ipsum dolor sit amet: consect 2018_12_06_21_06_00", + new TimerInfo + { + Name = "Lorem ipsum dolor sit amet: consect", + IsProgramSeries = true, + StartDate = new DateTime(2018, 12, 6, 21, 6, 0, DateTimeKind.Local), + OriginalAirDate = new DateTime(2018, 12, 6), + EpisodeTitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor" + }); + + return data; + } + + [Theory] + [MemberData(nameof(GetRecordingName_Success_TestData))] + public static void GetRecordingName_Success(string expected, TimerInfo timerInfo) + { + Assert.Equal(expected, RecordingHelper.GetRecordingName(timerInfo)); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs new file mode 100644 index 0000000000..6b1f4d4161 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos; +using Jellyfin.Extensions.Json; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.SchedulesDirect +{ + public class SchedulesDirectDeserializeTests + { + private readonly JsonSerializerOptions _jsonOptions; + + public SchedulesDirectDeserializeTests() + { + _jsonOptions = JsonDefaults.Options; + } + + /// + /// /token response. + /// + [Fact] + public void Deserialize_Token_Response_Live_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/token_live_response.json"); + var tokenDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(tokenDto); + Assert.Equal(0, tokenDto!.Code); + Assert.Equal("OK", tokenDto.Message); + Assert.Equal("AWS-SD-web.1", tokenDto.ServerId); + Assert.Equal(new DateTime(2016, 08, 23, 13, 55, 25, DateTimeKind.Utc), tokenDto.TokenTimestamp); + Assert.Equal("f3fca79989cafe7dead71beefedc812b", tokenDto.Token); + } + + /// + /// /token response. + /// + [Fact] + public void Deserialize_Token_Response_Offline_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/token_offline_response.json"); + var tokenDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(tokenDto); + Assert.Equal(3_000, tokenDto!.Code); + Assert.Equal("Server offline for maintenance.", tokenDto.Message); + Assert.Equal("20141201.web.1", tokenDto.ServerId); + Assert.Equal(new DateTime(2015, 04, 23, 00, 03, 32, DateTimeKind.Utc), tokenDto.TokenTimestamp); + Assert.Equal("CAFEDEADBEEFCAFEDEADBEEFCAFEDEADBEEFCAFE", tokenDto.Token); + Assert.Equal("SERVICE_OFFLINE", tokenDto.Response); + } + + /// + /// /schedules request. + /// + [Fact] + public void Serialize_Schedule_Request_Success() + { + var expectedString = File.ReadAllText("Test Data/SchedulesDirect/schedules_request.json").Trim(); + + var requestObject = new RequestScheduleForChannelDto[] + { + new RequestScheduleForChannelDto + { + StationId = "20454", + Date = new[] + { + "2015-03-13", + "2015-03-17" + } + }, + new RequestScheduleForChannelDto + { + StationId = "10021", + Date = new[] + { + "2015-03-12", + "2015-03-13" + } + } + }; + + var requestString = JsonSerializer.Serialize(requestObject, _jsonOptions); + Assert.Equal(expectedString, requestString); + } + + /// + /// /schedules response. + /// + [Fact] + public void Deserialize_Schedule_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/schedules_response.json"); + var days = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(days); + Assert.Single(days); + + var dayDto = days[0]; + Assert.Equal("20454", dayDto.StationId); + Assert.Equal(2, dayDto.Programs.Count); + + Assert.Equal("SH005371070000", dayDto.Programs[0].ProgramId); + Assert.Equal(new DateTime(2015, 03, 03, 00, 00, 00, DateTimeKind.Utc), dayDto.Programs[0].AirDateTime); + Assert.Equal(1_800, dayDto.Programs[0].Duration); + Assert.Equal("Sy8HEMBPcuiAx3FBukUhKQ", dayDto.Programs[0].Md5); + Assert.True(dayDto.Programs[0].New); + Assert.Equal(2, dayDto.Programs[0].AudioProperties.Count); + Assert.Equal("stereo", dayDto.Programs[0].AudioProperties[0]); + Assert.Equal("cc", dayDto.Programs[0].AudioProperties[1]); + Assert.Single(dayDto.Programs[0].VideoProperties); + Assert.Equal("hdtv", dayDto.Programs[0].VideoProperties[0]); + } + + /// + /// /programs response. + /// + [Fact] + public void Deserialize_Program_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/programs_response.json"); + var programDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(programDtos); + Assert.Equal(2, programDtos!.Count); + Assert.Equal("EP000000060003", programDtos[0].ProgramId); + Assert.Single(programDtos[0].Titles); + Assert.Equal("'Allo 'Allo!", programDtos[0].Titles[0].Title120); + Assert.Equal("Series", programDtos[0].EventDetails?.SubType); + Assert.Equal("en", programDtos[0].Descriptions?.Description1000[0].DescriptionLanguage); + Assert.Equal("A disguised British Intelligence officer is sent to help the airmen.", programDtos[0].Descriptions?.Description1000[0].Description); + Assert.Equal(new DateTime(1985, 11, 04), programDtos[0].OriginalAirDate); + Assert.Single(programDtos[0].Genres); + Assert.Equal("Sitcom", programDtos[0].Genres[0]); + Assert.Equal("The Poloceman Cometh", programDtos[0].EpisodeTitle150); + Assert.Equal(2, programDtos[0].Metadata[0].Gracenote?.Season); + Assert.Equal(3, programDtos[0].Metadata[0].Gracenote?.Episode); + Assert.Equal(13, programDtos[0].Cast.Count); + Assert.Equal("383774", programDtos[0].Cast[0].PersonId); + Assert.Equal("392649", programDtos[0].Cast[0].NameId); + Assert.Equal("Gorden Kaye", programDtos[0].Cast[0].Name); + Assert.Equal("Actor", programDtos[0].Cast[0].Role); + Assert.Equal("01", programDtos[0].Cast[0].BillingOrder); + Assert.Equal(3, programDtos[0].Crew.Count); + Assert.Equal("354407", programDtos[0].Crew[0].PersonId); + Assert.Equal("363281", programDtos[0].Crew[0].NameId); + Assert.Equal("David Croft", programDtos[0].Crew[0].Name); + Assert.Equal("Director", programDtos[0].Crew[0].Role); + Assert.Equal("01", programDtos[0].Crew[0].BillingOrder); + } + + /// + /// /metadata/programs response. + /// + [Fact] + public void Deserialize_Metadata_Programs_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/metadata_programs_response.json"); + var showImagesDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(showImagesDtos); + Assert.Single(showImagesDtos!); + Assert.Equal("SH00712240", showImagesDtos[0].ProgramId); + Assert.Equal(4, showImagesDtos[0].Data.Count); + Assert.Equal("135", showImagesDtos[0].Data[0].Width); + Assert.Equal("180", showImagesDtos[0].Data[0].Height); + Assert.Equal("assets/p282288_b_v2_aa.jpg", showImagesDtos[0].Data[0].Uri); + Assert.Equal("Sm", showImagesDtos[0].Data[0].Size); + Assert.Equal("3x4", showImagesDtos[0].Data[0].Aspect); + Assert.Equal("Banner-L3", showImagesDtos[0].Data[0].Category); + Assert.Equal("yes", showImagesDtos[0].Data[0].Text); + Assert.Equal("true", showImagesDtos[0].Data[0].Primary); + Assert.Equal("Series", showImagesDtos[0].Data[0].Tier); + } + + /// + /// /headends response. + /// + [Fact] + public void Deserialize_Headends_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/headends_response.json"); + var headendsDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(headendsDtos); + Assert.Equal(8, headendsDtos!.Count); + Assert.Equal("CA00053", headendsDtos[0].Headend); + Assert.Equal("Cable", headendsDtos[0].Transport); + Assert.Equal("Beverly Hills", headendsDtos[0].Location); + Assert.Equal(2, headendsDtos[0].Lineups.Count); + Assert.Equal("Time Warner Cable - Cable", headendsDtos[0].Lineups[0].Name); + Assert.Equal("USA-CA00053-DEFAULT", headendsDtos[0].Lineups[0].Lineup); + Assert.Equal("/20141201/lineups/USA-CA00053-DEFAULT", headendsDtos[0].Lineups[0].Uri); + } + + /// + /// /lineups response. + /// + [Fact] + public void Deserialize_Lineups_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/lineups_response.json"); + var lineupsDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(lineupsDto); + Assert.Equal(0, lineupsDto!.Code); + Assert.Equal("20141201.web.1", lineupsDto.ServerId); + Assert.Equal(new DateTime(2015, 04, 17, 14, 22, 17, DateTimeKind.Utc), lineupsDto.LineupTimestamp); + Assert.Equal(5, lineupsDto.Lineups.Count); + Assert.Equal("GBR-0001317-DEFAULT", lineupsDto.Lineups[0].Lineup); + Assert.Equal("Freeview - Carlton - LWT (Southeast)", lineupsDto.Lineups[0].Name); + Assert.Equal("DVB-T", lineupsDto.Lineups[0].Transport); + Assert.Equal("London", lineupsDto.Lineups[0].Location); + Assert.Equal("/20141201/lineups/GBR-0001317-DEFAULT", lineupsDto.Lineups[0].Uri); + + Assert.Equal("DELETED LINEUP", lineupsDto.Lineups[4].Name); + Assert.True(lineupsDto.Lineups[4].IsDeleted); + } + + /// + /// /lineup/:id response. + /// + [Fact] + public void Deserialize_Lineup_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/lineup_response.json"); + var channelDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(channelDto); + Assert.Equal(2, channelDto!.Map.Count); + Assert.Equal("24326", channelDto.Map[0].StationId); + Assert.Equal("001", channelDto.Map[0].Channel); + Assert.Equal("BBC ONE South", channelDto.Map[0].ProvderCallsign); + Assert.Equal("1", channelDto.Map[0].LogicalChannelNumber); + Assert.Equal("providerCallsign", channelDto.Map[0].MatchType); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/discover.json b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/discover.json new file mode 100644 index 0000000000..a4ad4ed444 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/discover.json @@ -0,0 +1 @@ +{"FriendlyName":"HDHomeRun DUAL","ModelNumber":"HDHR3-US","Legacy":1,"FirmwareName":"hdhomerun3_atsc","FirmwareVersion":"20200225","DeviceID":"10xxxxx5","TunerCount":2,"BaseURL":"http://10.10.10.100:80"} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/lineup.json b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/lineup.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/lineup.json @@ -0,0 +1 @@ +{} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/discover.json b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/discover.json new file mode 100644 index 0000000000..851f17bb26 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/discover.json @@ -0,0 +1 @@ +{"FriendlyName":"HDHomeRun PRIME","ModelNumber":"HDHR3-CC","FirmwareName":"hdhomerun3_cablecard","FirmwareVersion":"20160630atest2","DeviceID":"FFFFFFFF","DeviceAuth":"FFFFFFFF","TunerCount":3,"ConditionalAccess":1,"BaseURL":"http://192.168.1.182:80","LineupURL":"http://192.168.1.182:80/lineup.json"} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/lineup.json b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/lineup.json new file mode 100644 index 0000000000..4cb5ebc8ec --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/lineup.json @@ -0,0 +1 @@ +[ { "GuideNumber": "4.1", "GuideName": "WCMH-DT", "HD": 1, "Favorite": 1, "URL": "http://192.168.1.111:5004/auto/v4.1" }, { "GuideNumber": "4.2", "GuideName": "MeTV", "URL": "http://192.168.1.111:5004/auto/v4.2" }, { "GuideNumber": "4.3", "GuideName": "ION TV", "URL": "http://192.168.1.111:5004/auto/v4.3" }, { "GuideNumber": "6.1", "GuideName": "WSYX DT", "HD": 1, "URL": "http://192.168.1.111:5004/auto/v6.1" }, { "GuideNumber": "6.2", "GuideName": "MYTV", "URL": "http://192.168.1.111:5004/auto/v6.2" }, { "GuideNumber": "6.3", "GuideName": "ANTENNA", "URL": "http://192.168.1.111:5004/auto/v6.3" } ] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml new file mode 100644 index 0000000000..dd4aa89774 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml @@ -0,0 +1,6 @@ + + + + sports + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml new file mode 100644 index 0000000000..5a5be79976 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml @@ -0,0 +1,10 @@ + + + sports + 2022-11-04 13:00:00 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/headends_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/headends_response.json new file mode 100644 index 0000000000..015afeecc0 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/headends_response.json @@ -0,0 +1 @@ +[{"headend":"CA00053","transport":"Cable","location":"Beverly Hills","lineups":[{"name":"Time Warner Cable - Cable","lineup":"USA-CA00053-DEFAULT","uri":"/20141201/lineups/USA-CA00053-DEFAULT"},{"name":"Time Warner Cable - Digital","lineup":"USA-CA00053-X","uri":"/20141201/lineups/USA-CA00053-X"}]},{"headend":"CA61222","transport":"Cable","location":"Beverly Hills","lineups":[{"name":"Mulholland Estates - Cable","lineup":"USA-CA61222-DEFAULT","uri":"/20141201/lineups/USA-CA61222-DEFAULT"}]},{"headend":"CA66511","transport":"Cable","location":"Los Angeles","lineups":[{"name":"AT&T U-verse TV - Digital","lineup":"USA-CA66511-X","uri":"/20141201/lineups/USA-CA66511-X"}]},{"headend":"CA67309","transport":"Cable","location":"Westchester","lineups":[{"name":"Time Warner Cable Sherman Oaks - Cable","lineup":"USA-CA67309-DEFAULT","uri":"/20141201/lineups/USA-CA67309-DEFAULT"},{"name":"Time Warner Cable Sherman Oaks - Digital","lineup":"USA-CA67309-X","uri":"/20141201/lineups/USA-CA67309-X"}]},{"headend":"CA67310","transport":"Cable","location":"Eagle Rock","lineups":[{"name":"Time Warner Cable City of Los Angeles - Cable","lineup":"USA-CA67310-DEFAULT","uri":"/20141201/lineups/USA-CA67310-DEFAULT"},{"name":"Time Warner Cable City of Los Angeles - Digital","lineup":"USA-CA67310-X","uri":"/20141201/lineups/USA-CA67310-X"}]},{"headend":"DISH803","transport":"Satellite","location":"Los Angeles","lineups":[{"name":"DISH Los Angeles - Satellite","lineup":"USA-DISH803-DEFAULT","uri":"/20141201/lineups/USA-DISH803-DEFAULT"}]},{"headend":"DITV803","transport":"Satellite","location":"Los Angeles","lineups":[{"name":"DIRECTV Los Angeles - Satellite","lineup":"USA-DITV803-DEFAULT","uri":"/20141201/lineups/USA-DITV803-DEFAULT"}]},{"headend":"90210","transport":"Antenna","location":"90210","lineups":[{"name":"Antenna","lineup":"USA-OTA-90210","uri":"/20141201/lineups/USA-OTA-90210"}]}] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineup_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineup_response.json new file mode 100644 index 0000000000..0720894702 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineup_response.json @@ -0,0 +1 @@ +{"map":[{"stationID":"24326","channel":"001","providerCallsign":"BBC ONE South","logicalChannelNumber":"1","matchType":"providerCallsign"},{"stationID":"17154","channel":"002","providerCallsign":"BBC TWO","logicalChannelNumber":"2","matchType":"providerCallsign"}]} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineups_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineups_response.json new file mode 100644 index 0000000000..032a84e59e --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineups_response.json @@ -0,0 +1 @@ +{"code":0,"serverID":"20141201.web.1","datetime":"2015-04-17T14:22:17Z","lineups":[{"lineup":"GBR-0001317-DEFAULT","name":"Freeview - Carlton - LWT (Southeast)","transport":"DVB-T","location":"London","uri":"/20141201/lineups/GBR-0001317-DEFAULT"},{"lineup":"USA-IL57303-X","name":"Comcast Waukegan/Lake Forest Area - Digital","transport":"Cable","location":"Lake Forest","uri":"/20141201/lineups/USA-IL57303-X"},{"lineup":"USA-NY67791-X","name":"Verizon Fios Queens - Digital","transport":"Cable","location":"Fresh Meadows","uri":"/20141201/lineups/USA-NY67791-X"},{"lineup":"USA-OTA-60030","name":"Local Over the Air Broadcast","transport":"Antenna","location":"60030","uri":"/20141201/lineups/USA-OTA-60030"},{"lineup":"USA-WI61859-DEFAULT","name":"DELETED LINEUP","isDeleted":true}]} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_response.json new file mode 100644 index 0000000000..78166f09a4 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_response.json @@ -0,0 +1 @@ +[{"programID":"SH00712240","data":[{"width":"135","height":"180","uri":"assets/p282288_b_v2_aa.jpg","size":"Sm","aspect":"3x4","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"720","height":"540","uri":"assets/p282288_b_h6_aa.jpg","size":"Lg","aspect":"4x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"960","height":"1440","uri":"assets/p282288_b_v8_aa.jpg","size":"Ms","aspect":"2x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"180","height":"135","uri":"assets/p282288_b_h5_aa.jpg","size":"Sm","aspect":"4x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"}]}] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/programs_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/programs_response.json new file mode 100644 index 0000000000..fe2a944364 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/programs_response.json @@ -0,0 +1 @@ +[{"programID":"EP000000060003","titles":[{"title120":"'Allo 'Allo!"}],"eventDetails":{"subType":"Series"},"descriptions":{"description1000":[{"descriptionLanguage":"en","description":"A disguised British Intelligence officer is sent to help the airmen."}]},"originalAirDate":"1985-11-04","genres":["Sitcom"],"episodeTitle150":"The Poloceman Cometh","metadata":[{"Gracenote":{"season":2,"episode":3}}],"cast":[{"personId":"383774","nameId":"392649","name":"Gorden Kaye","role":"Actor","billingOrder":"01"},{"personId":"246840","nameId":"250387","name":"Carmen Silvera","role":"Actor","billingOrder":"02"},{"personId":"376955","nameId":"385830","name":"Rose Hill","role":"Actor","billingOrder":"03"},{"personId":"259773","nameId":"263340","name":"Vicki Michelle","role":"Actor","billingOrder":"04"},{"personId":"353113","nameId":"361987","name":"Kirsten Cooke","role":"Actor","billingOrder":"05"},{"personId":"77787","nameId":"77787","name":"Richard Marner","role":"Actor","billingOrder":"06"},{"personId":"230921","nameId":"234193","name":"Guy Siner","role":"Actor","billingOrder":"07"},{"personId":"374934","nameId":"383809","name":"Kim Hartman","role":"Actor","billingOrder":"08"},{"personId":"369151","nameId":"378026","name":"Richard Gibson","role":"Actor","billingOrder":"09"},{"personId":"343690","nameId":"352564","name":"Arthur Bostrom","role":"Actor","billingOrder":"10"},{"personId":"352557","nameId":"361431","name":"John D. Collins","role":"Actor","billingOrder":"11"},{"personId":"605275","nameId":"627734","name":"Nicholas Frankau","role":"Actor","billingOrder":"12"},{"personId":"373394","nameId":"382269","name":"Jack Haig","role":"Actor","billingOrder":"13"}],"crew":[{"personId":"354407","nameId":"363281","name":"David Croft","role":"Director","billingOrder":"01"},{"personId":"354407","nameId":"363281","name":"David Croft","role":"Writer","billingOrder":"02"},{"personId":"105145","nameId":"105145","name":"Jeremy Lloyd","role":"Writer","billingOrder":"03"}],"showType":"Series","hasImageArtwork":true,"md5":"Jo5NKxoo44xRvBCAq8QT2A"},{"programID":"EP000000510142","titles":[{"title120":"A Different World"}],"eventDetails":{"subType":"Series"},"descriptions":{"description1000":[{"descriptionLanguage":"en","description":"Whitley and Dwayne tell new students about their honeymoon in Los Angeles."}]},"originalAirDate":"1992-09-24","genres":["Sitcom"],"episodeTitle150":"Honeymoon in L.A.","metadata":[{"Gracenote":{"season":6,"episode":1}}],"cast":[{"personId":"700","nameId":"700","name":"Jasmine Guy","role":"Actor","billingOrder":"01"},{"personId":"729","nameId":"729","name":"Kadeem Hardison","role":"Actor","billingOrder":"02"},{"personId":"120","nameId":"120","name":"Darryl M. Bell","role":"Actor","billingOrder":"03"},{"personId":"1729","nameId":"1729","name":"Cree Summer","role":"Actor","billingOrder":"04"},{"personId":"217","nameId":"217","name":"Charnele Brown","role":"Actor","billingOrder":"05"},{"personId":"1811","nameId":"1811","name":"Glynn Turman","role":"Actor","billingOrder":"06"},{"personId":"1232","nameId":"1232","name":"Lou Myers","role":"Actor","billingOrder":"07"},{"personId":"1363","nameId":"1363","name":"Jada Pinkett","role":"Guest Star","billingOrder":"08"},{"personId":"222967","nameId":"225536","name":"Ajai Sanders","role":"Guest Star","billingOrder":"09"},{"personId":"181744","nameId":"183292","name":"Karen Malina White","role":"Guest Star","billingOrder":"10"},{"personId":"305017","nameId":"318897","name":"Patrick Y. Malone","role":"Guest Star","billingOrder":"11"},{"personId":"9841","nameId":"9841","name":"Bumper Robinson","role":"Guest Star","billingOrder":"12"},{"personId":"426422","nameId":"435297","name":"Sister Souljah","role":"Guest Star","billingOrder":"13"},{"personId":"25","nameId":"25","name":"Debbie Allen","role":"Guest Star","billingOrder":"14"},{"personId":"668","nameId":"668","name":"Gilbert Gottfried","role":"Guest Star","billingOrder":"15"}],"showType":"Series","hasImageArtwork":true,"md5":"P5kz0QmCeYxIA+yL0H4DWw"}] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_request.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_request.json new file mode 100644 index 0000000000..5ef1bfb1c4 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_request.json @@ -0,0 +1 @@ +[{"stationID":"20454","date":["2015-03-13","2015-03-17"]},{"stationID":"10021","date":["2015-03-12","2015-03-13"]}] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_response.json new file mode 100644 index 0000000000..4a97e55172 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_response.json @@ -0,0 +1 @@ +[{"stationID":"20454","programs":[{"programID":"SH005371070000","airDateTime":"2015-03-03T00:00:00Z","duration":1800,"md5":"Sy8HEMBPcuiAx3FBukUhKQ","new":true,"audioProperties":["stereo","cc"],"videoProperties":["hdtv"]},{"programID":"EP000014577244","airDateTime":"2015-03-03T00:30:00Z","duration":1800,"md5":"25DNXVXO192JI7Y9vSW9lQ","new":true,"audioProperties":["stereo","cc"],"videoProperties":["hdtv"]}]}] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_live_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_live_response.json new file mode 100644 index 0000000000..e5fb64a6fa --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_live_response.json @@ -0,0 +1 @@ +{"code":0,"message":"OK","serverID":"AWS-SD-web.1","datetime":"2016-08-23T13:55:25Z","token":"f3fca79989cafe7dead71beefedc812b"} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_offline_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_offline_response.json new file mode 100644 index 0000000000..b66a4ed0c4 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_offline_response.json @@ -0,0 +1 @@ +{"response":"SERVICE_OFFLINE","code":3000,"serverID":"20141201.web.1","message":"Server offline for maintenance.","datetime":"2015-04-23T00:03:32Z","token":"CAFEDEADBEEFCAFEDEADBEEFCAFEDEADBEEFCAFE"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs deleted file mode 100644 index 13ac3ddb0f..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using System.IO; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using AutoFixture; -using AutoFixture.AutoMoq; -using Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun; -using MediaBrowser.Model.LiveTv; -using Moq; -using Moq.Protected; -using Xunit; - -namespace Jellyfin.Server.Implementations.Tests.LiveTv -{ - public class HdHomerunHostTests - { - private readonly Fixture _fixture; - private readonly HdHomerunHost _hdHomerunHost; - - public HdHomerunHostTests() - { - var messageHandler = new Mock(); - messageHandler.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .Returns( - (m, _) => - { - return Task.FromResult(new HttpResponseMessage() - { - Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv", m.RequestUri!.Host, m.RequestUri.Segments[^1]))) - }); - }); - - var http = new Mock(); - http.Setup(x => x.CreateClient(It.IsAny())) - .Returns(new HttpClient(messageHandler.Object)); - _fixture = new Fixture(); - _fixture.Customize(new AutoMoqCustomization - { - ConfigureMembers = true - }).Inject(http); - _hdHomerunHost = _fixture.Create(); - } - - [Fact] - public async Task GetModelInfo_Valid_Success() - { - var host = new TunerHostInfo() - { - Url = "192.168.1.182" - }; - - var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None); - Assert.Equal("HDHomeRun PRIME", modelInfo.FriendlyName); - Assert.Equal("HDHR3-CC", modelInfo.ModelNumber); - Assert.Equal("hdhomerun3_cablecard", modelInfo.FirmwareName); - Assert.Equal("20160630atest2", modelInfo.FirmwareVersion); - Assert.Equal("FFFFFFFF", modelInfo.DeviceID); - Assert.Equal("FFFFFFFF", modelInfo.DeviceAuth); - Assert.Equal(3, modelInfo.TunerCount); - Assert.Equal("http://192.168.1.182:80", modelInfo.BaseURL); - Assert.Equal("http://192.168.1.182:80/lineup.json", modelInfo.LineupURL); - } - - [Fact] - public async Task GetModelInfo_Legacy_Success() - { - var host = new TunerHostInfo() - { - Url = "10.10.10.100" - }; - - var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None); - Assert.Equal("HDHomeRun DUAL", modelInfo.FriendlyName); - Assert.Equal("HDHR3-US", modelInfo.ModelNumber); - Assert.Equal("hdhomerun3_atsc", modelInfo.FirmwareName); - Assert.Equal("20200225", modelInfo.FirmwareVersion); - Assert.Equal("10xxxxx5", modelInfo.DeviceID); - Assert.Null(modelInfo.DeviceAuth); - Assert.Equal(2, modelInfo.TunerCount); - Assert.Equal("http://10.10.10.100:80", modelInfo.BaseURL); - Assert.Null(modelInfo.LineupURL); - } - - [Fact] - public async Task GetModelInfo_EmptyUrl_ArgumentException() - { - var host = new TunerHostInfo() - { - Url = string.Empty - }; - - await Assert.ThrowsAsync(() => _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None)); - } - - [Fact] - public async Task GetLineup_Valid_Success() - { - var host = new TunerHostInfo() - { - Url = "192.168.1.182" - }; - - var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None); - Assert.Equal(6, channels.Count); - Assert.Equal("4.1", channels[0].GuideNumber); - Assert.Equal("WCMH-DT", channels[0].GuideName); - Assert.True(channels[0].HD); - Assert.True(channels[0].Favorite); - Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); - } - - [Fact] - public async Task GetLineup_Legacy_Success() - { - var host = new TunerHostInfo() - { - Url = "10.10.10.100" - }; - - // Placeholder json is invalid, just need to make sure we can reach it - await Assert.ThrowsAsync(() => _hdHomerunHost.GetLineup(host, CancellationToken.None)); - } - - [Fact] - public async Task GetLineup_ImportFavoritesOnly_Success() - { - var host = new TunerHostInfo() - { - Url = "192.168.1.182", - ImportFavoritesOnly = true - }; - - var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None); - Assert.Single(channels); - Assert.Equal("4.1", channels[0].GuideNumber); - Assert.Equal("WCMH-DT", channels[0].GuideName); - Assert.True(channels[0].HD); - Assert.True(channels[0].Favorite); - Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); - } - - [Fact] - public async Task TryGetTunerHostInfo_Valid_Success() - { - var host = await _hdHomerunHost.TryGetTunerHostInfo("192.168.1.182", CancellationToken.None); - Assert.Equal(_hdHomerunHost.Type, host.Type); - Assert.Equal("192.168.1.182", host.Url); - Assert.Equal("HDHomeRun PRIME", host.FriendlyName); - Assert.Equal("FFFFFFFF", host.DeviceId); - Assert.Equal(3, host.TunerCount); - } - } -} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunManagerTests.cs deleted file mode 100644 index fd499d9cf9..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunManagerTests.cs +++ /dev/null @@ -1,326 +0,0 @@ -using System; -using System.Text; -using Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun; -using Xunit; - -namespace Jellyfin.Server.Implementations.Tests.LiveTv -{ - public class HdHomerunManagerTests - { - [Fact] - public void WriteNullTerminatedString_Empty_Success() - { - ReadOnlySpan expected = stackalloc byte[] - { - 1, 0 - }; - - Span buffer = stackalloc byte[128]; - int len = HdHomerunManager.WriteNullTerminatedString(buffer, string.Empty); - - Assert.Equal( - Convert.ToHexString(expected), - Convert.ToHexString(buffer.Slice(0, len))); - } - - [Fact] - public void WriteNullTerminatedString_Valid_Success() - { - ReadOnlySpan expected = stackalloc byte[] - { - 10, (byte)'T', (byte)'h', (byte)'e', (byte)' ', (byte)'q', (byte)'u', (byte)'i', (byte)'c', (byte)'k', 0 - }; - - Span buffer = stackalloc byte[128]; - int len = HdHomerunManager.WriteNullTerminatedString(buffer, "The quick"); - - Assert.Equal( - Convert.ToHexString(expected), - Convert.ToHexString(buffer.Slice(0, len))); - } - - [Fact] - public void WriteGetMessage_Valid_Success() - { - ReadOnlySpan expected = stackalloc byte[] - { - 0, 4, - 0, 12, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 0xc0, 0xc9, 0x87, 0x33 - }; - - Span buffer = stackalloc byte[128]; - int len = HdHomerunManager.WriteGetMessage(buffer, 0, "N"); - - Assert.Equal( - Convert.ToHexString(expected), - Convert.ToHexString(buffer.Slice(0, len))); - } - - [Fact] - public void WriteSetMessage_NoLockKey_Success() - { - ReadOnlySpan expected = stackalloc byte[] - { - 0, 4, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xa9, 0x49, 0xd0, 0x68 - }; - - Span buffer = stackalloc byte[128]; - int len = HdHomerunManager.WriteSetMessage(buffer, 0, "N", "value", null); - - Assert.Equal( - Convert.ToHexString(expected), - Convert.ToHexString(buffer.Slice(0, len))); - } - - [Fact] - public void WriteSetMessage_LockKey_Success() - { - ReadOnlySpan expected = stackalloc byte[] - { - 0, 4, - 0, 26, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 21, - 4, 0x00, 0x01, 0x38, 0xd5, - 0x8e, 0xb6, 0x06, 0x82 - }; - - Span buffer = stackalloc byte[128]; - int len = HdHomerunManager.WriteSetMessage(buffer, 0, "N", "value", 80085); - - Assert.Equal( - Convert.ToHexString(expected), - Convert.ToHexString(buffer.Slice(0, len))); - } - - [Fact] - public void TryGetReturnValueOfGetSet_Valid_Success() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x7d, 0xa3, 0xa3, 0xf3 - }; - - Assert.True(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out var value)); - Assert.Equal("value", Encoding.UTF8.GetString(value)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_InvalidCrc_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x7d, 0xa3, 0xa3, 0xf4 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_InvalidPacketType_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 4, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xa9, 0x49, 0xd0, 0x68 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_InvalidPacket_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 0x7d, 0xa3, 0xa3 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_TooSmallMessageLength_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 19, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x25, 0x25, 0x44, 0x9a - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_TooLargeMessageLength_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 21, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xe3, 0x20, 0x79, 0x6c - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_TooLargeNameLength_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 20, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xe1, 0x8e, 0x9c, 0x74 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_InvalidGetSetNameTag_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 4, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xee, 0x05, 0xe7, 0x12 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_InvalidGetSetValueTag_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 3, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x64, 0xaa, 0x66, 0xf9 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_TooLargeValueLength_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 7, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xc9, 0xa8, 0xd4, 0x55 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void VerifyReturnValueOfGetSet_Valid_True() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x7d, 0xa3, 0xa3, 0xf3 - }; - - Assert.True(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "value")); - } - - [Fact] - public void VerifyReturnValueOfGetSet_WrongValue_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x7d, 0xa3, 0xa3, 0xf3 - }; - - Assert.False(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "none")); - } - - [Fact] - public void VerifyReturnValueOfGetSet_InvalidPacket_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 4, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x7d, 0xa3, 0xa3, 0xf3 - }; - - Assert.False(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "value")); - } - } -} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs deleted file mode 100644 index 92b4178fdb..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using AutoFixture; -using AutoFixture.AutoMoq; -using Emby.Server.Implementations.LiveTv.Listings; -using MediaBrowser.Model.LiveTv; -using Moq; -using Moq.Protected; -using Xunit; - -namespace Jellyfin.Server.Implementations.Tests.LiveTv.Listings; - -public class XmlTvListingsProviderTests -{ - private readonly Fixture _fixture; - private readonly XmlTvListingsProvider _xmlTvListingsProvider; - - public XmlTvListingsProviderTests() - { - var messageHandler = new Mock(); - messageHandler.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .Returns( - (m, _) => - { - return Task.FromResult(new HttpResponseMessage() - { - Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv/Listings/XmlTv", m.RequestUri!.Segments[^1]))) - }); - }); - - var http = new Mock(); - http.Setup(x => x.CreateClient(It.IsAny())) - .Returns(new HttpClient(messageHandler.Object)); - _fixture = new Fixture(); - _fixture.Customize(new AutoMoqCustomization - { - ConfigureMembers = true - }).Inject(http); - _xmlTvListingsProvider = _fixture.Create(); - } - - [Theory] - [InlineData("Test Data/LiveTv/Listings/XmlTv/notitle.xml")] - [InlineData("https://example.com/notitle.xml")] - public async Task GetProgramsAsync_NoTitle_Success(string path) - { - var info = new ListingsProviderInfo() - { - Path = path - }; - - var startDate = new DateTime(2022, 11, 4); - var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); - var programsList = programs.ToList(); - Assert.Single(programsList); - var program = programsList[0]; - Assert.Null(program.Name); - Assert.Null(program.SeriesId); - Assert.Null(program.EpisodeTitle); - Assert.True(program.IsSports); - Assert.True(program.HasImage); - Assert.Equal("https://domain.tld/image.png", program.ImageUrl); - Assert.Equal("3297", program.ChannelId); - } - - [Theory] - [InlineData("Test Data/LiveTv/Listings/XmlTv/emptycategory.xml")] - [InlineData("https://example.com/emptycategory.xml")] - public async Task GetProgramsAsync_EmptyCategories_Success(string path) - { - var info = new ListingsProviderInfo() - { - Path = path - }; - - var startDate = new DateTime(2022, 11, 4); - var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); - var programsList = programs.ToList(); - Assert.Single(programsList); - var program = programsList[0]; - Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g)); - Assert.Equal("3297", program.ChannelId); - } -} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs deleted file mode 100644 index f107b1ef97..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using Emby.Server.Implementations.LiveTv.EmbyTV; -using MediaBrowser.Controller.LiveTv; -using Xunit; - -namespace Jellyfin.Server.Implementations.Tests.LiveTv -{ - public static class RecordingHelperTests - { - public static TheoryData GetRecordingName_Success_TestData() - { - var data = new TheoryData(); - - data.Add( - "The Incredibles 2020_04_20_21_06_00", - new TimerInfo - { - Name = "The Incredibles", - StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), - IsMovie = true - }); - - data.Add( - "The Incredibles (2004)", - new TimerInfo - { - Name = "The Incredibles", - IsMovie = true, - ProductionYear = 2004 - }); - data.Add( - "The Big Bang Theory 2020_04_20_21_06_00", - new TimerInfo - { - Name = "The Big Bang Theory", - StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), - IsProgramSeries = true, - }); - data.Add( - "The Big Bang Theory S12E10", - new TimerInfo - { - Name = "The Big Bang Theory", - IsProgramSeries = true, - SeasonNumber = 12, - EpisodeNumber = 10 - }); - data.Add( - "The Big Bang Theory S12E10 The VCR Illumination", - new TimerInfo - { - Name = "The Big Bang Theory", - IsProgramSeries = true, - SeasonNumber = 12, - EpisodeNumber = 10, - EpisodeTitle = "The VCR Illumination" - }); - data.Add( - "The Big Bang Theory 2018-12-06", - new TimerInfo - { - Name = "The Big Bang Theory", - IsProgramSeries = true, - OriginalAirDate = new DateTime(2018, 12, 6, 0, 0, 0, DateTimeKind.Local) - }); - - data.Add( - "The Big Bang Theory 2018-12-06 - The VCR Illumination", - new TimerInfo - { - Name = "The Big Bang Theory", - IsProgramSeries = true, - OriginalAirDate = new DateTime(2018, 12, 6, 0, 0, 0, DateTimeKind.Local), - EpisodeTitle = "The VCR Illumination" - }); - - data.Add( - "The Big Bang Theory 2018_12_06_21_06_00 - The VCR Illumination", - new TimerInfo - { - Name = "The Big Bang Theory", - StartDate = new DateTime(2018, 12, 6, 21, 6, 0, DateTimeKind.Local), - IsProgramSeries = true, - OriginalAirDate = new DateTime(2018, 12, 6), - EpisodeTitle = "The VCR Illumination" - }); - - data.Add( - "Lorem ipsum dolor sit amet: consect 2018_12_06_21_06_00", - new TimerInfo - { - Name = "Lorem ipsum dolor sit amet: consect", - IsProgramSeries = true, - StartDate = new DateTime(2018, 12, 6, 21, 6, 0, DateTimeKind.Local), - OriginalAirDate = new DateTime(2018, 12, 6), - EpisodeTitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor" - }); - - return data; - } - - [Theory] - [MemberData(nameof(GetRecordingName_Success_TestData))] - public static void GetRecordingName_Success(string expected, TimerInfo timerInfo) - { - Assert.Equal(expected, RecordingHelper.GetRecordingName(timerInfo)); - } - } -} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs deleted file mode 100644 index d4f28f327f..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos; -using Jellyfin.Extensions.Json; -using Xunit; - -namespace Jellyfin.Server.Implementations.Tests.LiveTv.SchedulesDirect -{ - public class SchedulesDirectDeserializeTests - { - private readonly JsonSerializerOptions _jsonOptions; - - public SchedulesDirectDeserializeTests() - { - _jsonOptions = JsonDefaults.Options; - } - - /// - /// /token response. - /// - [Fact] - public void Deserialize_Token_Response_Live_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/token_live_response.json"); - var tokenDto = JsonSerializer.Deserialize(bytes, _jsonOptions); - - Assert.NotNull(tokenDto); - Assert.Equal(0, tokenDto!.Code); - Assert.Equal("OK", tokenDto.Message); - Assert.Equal("AWS-SD-web.1", tokenDto.ServerId); - Assert.Equal(new DateTime(2016, 08, 23, 13, 55, 25, DateTimeKind.Utc), tokenDto.TokenTimestamp); - Assert.Equal("f3fca79989cafe7dead71beefedc812b", tokenDto.Token); - } - - /// - /// /token response. - /// - [Fact] - public void Deserialize_Token_Response_Offline_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/token_offline_response.json"); - var tokenDto = JsonSerializer.Deserialize(bytes, _jsonOptions); - - Assert.NotNull(tokenDto); - Assert.Equal(3_000, tokenDto!.Code); - Assert.Equal("Server offline for maintenance.", tokenDto.Message); - Assert.Equal("20141201.web.1", tokenDto.ServerId); - Assert.Equal(new DateTime(2015, 04, 23, 00, 03, 32, DateTimeKind.Utc), tokenDto.TokenTimestamp); - Assert.Equal("CAFEDEADBEEFCAFEDEADBEEFCAFEDEADBEEFCAFE", tokenDto.Token); - Assert.Equal("SERVICE_OFFLINE", tokenDto.Response); - } - - /// - /// /schedules request. - /// - [Fact] - public void Serialize_Schedule_Request_Success() - { - var expectedString = File.ReadAllText("Test Data/SchedulesDirect/schedules_request.json").Trim(); - - var requestObject = new RequestScheduleForChannelDto[] - { - new RequestScheduleForChannelDto - { - StationId = "20454", - Date = new[] - { - "2015-03-13", - "2015-03-17" - } - }, - new RequestScheduleForChannelDto - { - StationId = "10021", - Date = new[] - { - "2015-03-12", - "2015-03-13" - } - } - }; - - var requestString = JsonSerializer.Serialize(requestObject, _jsonOptions); - Assert.Equal(expectedString, requestString); - } - - /// - /// /schedules response. - /// - [Fact] - public void Deserialize_Schedule_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/schedules_response.json"); - var days = JsonSerializer.Deserialize>(bytes, _jsonOptions); - - Assert.NotNull(days); - Assert.Single(days); - - var dayDto = days[0]; - Assert.Equal("20454", dayDto.StationId); - Assert.Equal(2, dayDto.Programs.Count); - - Assert.Equal("SH005371070000", dayDto.Programs[0].ProgramId); - Assert.Equal(new DateTime(2015, 03, 03, 00, 00, 00, DateTimeKind.Utc), dayDto.Programs[0].AirDateTime); - Assert.Equal(1_800, dayDto.Programs[0].Duration); - Assert.Equal("Sy8HEMBPcuiAx3FBukUhKQ", dayDto.Programs[0].Md5); - Assert.True(dayDto.Programs[0].New); - Assert.Equal(2, dayDto.Programs[0].AudioProperties.Count); - Assert.Equal("stereo", dayDto.Programs[0].AudioProperties[0]); - Assert.Equal("cc", dayDto.Programs[0].AudioProperties[1]); - Assert.Single(dayDto.Programs[0].VideoProperties); - Assert.Equal("hdtv", dayDto.Programs[0].VideoProperties[0]); - } - - /// - /// /programs response. - /// - [Fact] - public void Deserialize_Program_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/programs_response.json"); - var programDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); - - Assert.NotNull(programDtos); - Assert.Equal(2, programDtos!.Count); - Assert.Equal("EP000000060003", programDtos[0].ProgramId); - Assert.Single(programDtos[0].Titles); - Assert.Equal("'Allo 'Allo!", programDtos[0].Titles[0].Title120); - Assert.Equal("Series", programDtos[0].EventDetails?.SubType); - Assert.Equal("en", programDtos[0].Descriptions?.Description1000[0].DescriptionLanguage); - Assert.Equal("A disguised British Intelligence officer is sent to help the airmen.", programDtos[0].Descriptions?.Description1000[0].Description); - Assert.Equal(new DateTime(1985, 11, 04), programDtos[0].OriginalAirDate); - Assert.Single(programDtos[0].Genres); - Assert.Equal("Sitcom", programDtos[0].Genres[0]); - Assert.Equal("The Poloceman Cometh", programDtos[0].EpisodeTitle150); - Assert.Equal(2, programDtos[0].Metadata[0].Gracenote?.Season); - Assert.Equal(3, programDtos[0].Metadata[0].Gracenote?.Episode); - Assert.Equal(13, programDtos[0].Cast.Count); - Assert.Equal("383774", programDtos[0].Cast[0].PersonId); - Assert.Equal("392649", programDtos[0].Cast[0].NameId); - Assert.Equal("Gorden Kaye", programDtos[0].Cast[0].Name); - Assert.Equal("Actor", programDtos[0].Cast[0].Role); - Assert.Equal("01", programDtos[0].Cast[0].BillingOrder); - Assert.Equal(3, programDtos[0].Crew.Count); - Assert.Equal("354407", programDtos[0].Crew[0].PersonId); - Assert.Equal("363281", programDtos[0].Crew[0].NameId); - Assert.Equal("David Croft", programDtos[0].Crew[0].Name); - Assert.Equal("Director", programDtos[0].Crew[0].Role); - Assert.Equal("01", programDtos[0].Crew[0].BillingOrder); - } - - /// - /// /metadata/programs response. - /// - [Fact] - public void Deserialize_Metadata_Programs_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/metadata_programs_response.json"); - var showImagesDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); - - Assert.NotNull(showImagesDtos); - Assert.Single(showImagesDtos!); - Assert.Equal("SH00712240", showImagesDtos[0].ProgramId); - Assert.Equal(4, showImagesDtos[0].Data.Count); - Assert.Equal("135", showImagesDtos[0].Data[0].Width); - Assert.Equal("180", showImagesDtos[0].Data[0].Height); - Assert.Equal("assets/p282288_b_v2_aa.jpg", showImagesDtos[0].Data[0].Uri); - Assert.Equal("Sm", showImagesDtos[0].Data[0].Size); - Assert.Equal("3x4", showImagesDtos[0].Data[0].Aspect); - Assert.Equal("Banner-L3", showImagesDtos[0].Data[0].Category); - Assert.Equal("yes", showImagesDtos[0].Data[0].Text); - Assert.Equal("true", showImagesDtos[0].Data[0].Primary); - Assert.Equal("Series", showImagesDtos[0].Data[0].Tier); - } - - /// - /// /headends response. - /// - [Fact] - public void Deserialize_Headends_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/headends_response.json"); - var headendsDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); - - Assert.NotNull(headendsDtos); - Assert.Equal(8, headendsDtos!.Count); - Assert.Equal("CA00053", headendsDtos[0].Headend); - Assert.Equal("Cable", headendsDtos[0].Transport); - Assert.Equal("Beverly Hills", headendsDtos[0].Location); - Assert.Equal(2, headendsDtos[0].Lineups.Count); - Assert.Equal("Time Warner Cable - Cable", headendsDtos[0].Lineups[0].Name); - Assert.Equal("USA-CA00053-DEFAULT", headendsDtos[0].Lineups[0].Lineup); - Assert.Equal("/20141201/lineups/USA-CA00053-DEFAULT", headendsDtos[0].Lineups[0].Uri); - } - - /// - /// /lineups response. - /// - [Fact] - public void Deserialize_Lineups_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/lineups_response.json"); - var lineupsDto = JsonSerializer.Deserialize(bytes, _jsonOptions); - - Assert.NotNull(lineupsDto); - Assert.Equal(0, lineupsDto!.Code); - Assert.Equal("20141201.web.1", lineupsDto.ServerId); - Assert.Equal(new DateTime(2015, 04, 17, 14, 22, 17, DateTimeKind.Utc), lineupsDto.LineupTimestamp); - Assert.Equal(5, lineupsDto.Lineups.Count); - Assert.Equal("GBR-0001317-DEFAULT", lineupsDto.Lineups[0].Lineup); - Assert.Equal("Freeview - Carlton - LWT (Southeast)", lineupsDto.Lineups[0].Name); - Assert.Equal("DVB-T", lineupsDto.Lineups[0].Transport); - Assert.Equal("London", lineupsDto.Lineups[0].Location); - Assert.Equal("/20141201/lineups/GBR-0001317-DEFAULT", lineupsDto.Lineups[0].Uri); - - Assert.Equal("DELETED LINEUP", lineupsDto.Lineups[4].Name); - Assert.True(lineupsDto.Lineups[4].IsDeleted); - } - - /// - /// /lineup/:id response. - /// - [Fact] - public void Deserialize_Lineup_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/lineup_response.json"); - var channelDto = JsonSerializer.Deserialize(bytes, _jsonOptions); - - Assert.NotNull(channelDto); - Assert.Equal(2, channelDto!.Map.Count); - Assert.Equal("24326", channelDto.Map[0].StationId); - Assert.Equal("001", channelDto.Map[0].Channel); - Assert.Equal("BBC ONE South", channelDto.Map[0].ProvderCallsign); - Assert.Equal("1", channelDto.Map[0].LogicalChannelNumber); - Assert.Equal("providerCallsign", channelDto.Map[0].MatchType); - } - } -} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/discover.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/discover.json deleted file mode 100644 index a4ad4ed444..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/discover.json +++ /dev/null @@ -1 +0,0 @@ -{"FriendlyName":"HDHomeRun DUAL","ModelNumber":"HDHR3-US","Legacy":1,"FirmwareName":"hdhomerun3_atsc","FirmwareVersion":"20200225","DeviceID":"10xxxxx5","TunerCount":2,"BaseURL":"http://10.10.10.100:80"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/lineup.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/lineup.json deleted file mode 100644 index 0967ef424b..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/lineup.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/discover.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/discover.json deleted file mode 100644 index 851f17bb26..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/discover.json +++ /dev/null @@ -1 +0,0 @@ -{"FriendlyName":"HDHomeRun PRIME","ModelNumber":"HDHR3-CC","FirmwareName":"hdhomerun3_cablecard","FirmwareVersion":"20160630atest2","DeviceID":"FFFFFFFF","DeviceAuth":"FFFFFFFF","TunerCount":3,"ConditionalAccess":1,"BaseURL":"http://192.168.1.182:80","LineupURL":"http://192.168.1.182:80/lineup.json"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/lineup.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/lineup.json deleted file mode 100644 index 4cb5ebc8ec..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/lineup.json +++ /dev/null @@ -1 +0,0 @@ -[ { "GuideNumber": "4.1", "GuideName": "WCMH-DT", "HD": 1, "Favorite": 1, "URL": "http://192.168.1.111:5004/auto/v4.1" }, { "GuideNumber": "4.2", "GuideName": "MeTV", "URL": "http://192.168.1.111:5004/auto/v4.2" }, { "GuideNumber": "4.3", "GuideName": "ION TV", "URL": "http://192.168.1.111:5004/auto/v4.3" }, { "GuideNumber": "6.1", "GuideName": "WSYX DT", "HD": 1, "URL": "http://192.168.1.111:5004/auto/v6.1" }, { "GuideNumber": "6.2", "GuideName": "MYTV", "URL": "http://192.168.1.111:5004/auto/v6.2" }, { "GuideNumber": "6.3", "GuideName": "ANTENNA", "URL": "http://192.168.1.111:5004/auto/v6.3" } ] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml deleted file mode 100644 index dd4aa89774..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - sports - - diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml deleted file mode 100644 index 5a5be79976..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - sports - 2022-11-04 13:00:00 - - - - diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/headends_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/headends_response.json deleted file mode 100644 index 015afeecc0..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/headends_response.json +++ /dev/null @@ -1 +0,0 @@ -[{"headend":"CA00053","transport":"Cable","location":"Beverly Hills","lineups":[{"name":"Time Warner Cable - Cable","lineup":"USA-CA00053-DEFAULT","uri":"/20141201/lineups/USA-CA00053-DEFAULT"},{"name":"Time Warner Cable - Digital","lineup":"USA-CA00053-X","uri":"/20141201/lineups/USA-CA00053-X"}]},{"headend":"CA61222","transport":"Cable","location":"Beverly Hills","lineups":[{"name":"Mulholland Estates - Cable","lineup":"USA-CA61222-DEFAULT","uri":"/20141201/lineups/USA-CA61222-DEFAULT"}]},{"headend":"CA66511","transport":"Cable","location":"Los Angeles","lineups":[{"name":"AT&T U-verse TV - Digital","lineup":"USA-CA66511-X","uri":"/20141201/lineups/USA-CA66511-X"}]},{"headend":"CA67309","transport":"Cable","location":"Westchester","lineups":[{"name":"Time Warner Cable Sherman Oaks - Cable","lineup":"USA-CA67309-DEFAULT","uri":"/20141201/lineups/USA-CA67309-DEFAULT"},{"name":"Time Warner Cable Sherman Oaks - Digital","lineup":"USA-CA67309-X","uri":"/20141201/lineups/USA-CA67309-X"}]},{"headend":"CA67310","transport":"Cable","location":"Eagle Rock","lineups":[{"name":"Time Warner Cable City of Los Angeles - Cable","lineup":"USA-CA67310-DEFAULT","uri":"/20141201/lineups/USA-CA67310-DEFAULT"},{"name":"Time Warner Cable City of Los Angeles - Digital","lineup":"USA-CA67310-X","uri":"/20141201/lineups/USA-CA67310-X"}]},{"headend":"DISH803","transport":"Satellite","location":"Los Angeles","lineups":[{"name":"DISH Los Angeles - Satellite","lineup":"USA-DISH803-DEFAULT","uri":"/20141201/lineups/USA-DISH803-DEFAULT"}]},{"headend":"DITV803","transport":"Satellite","location":"Los Angeles","lineups":[{"name":"DIRECTV Los Angeles - Satellite","lineup":"USA-DITV803-DEFAULT","uri":"/20141201/lineups/USA-DITV803-DEFAULT"}]},{"headend":"90210","transport":"Antenna","location":"90210","lineups":[{"name":"Antenna","lineup":"USA-OTA-90210","uri":"/20141201/lineups/USA-OTA-90210"}]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineup_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineup_response.json deleted file mode 100644 index 0720894702..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineup_response.json +++ /dev/null @@ -1 +0,0 @@ -{"map":[{"stationID":"24326","channel":"001","providerCallsign":"BBC ONE South","logicalChannelNumber":"1","matchType":"providerCallsign"},{"stationID":"17154","channel":"002","providerCallsign":"BBC TWO","logicalChannelNumber":"2","matchType":"providerCallsign"}]} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineups_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineups_response.json deleted file mode 100644 index 032a84e59e..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineups_response.json +++ /dev/null @@ -1 +0,0 @@ -{"code":0,"serverID":"20141201.web.1","datetime":"2015-04-17T14:22:17Z","lineups":[{"lineup":"GBR-0001317-DEFAULT","name":"Freeview - Carlton - LWT (Southeast)","transport":"DVB-T","location":"London","uri":"/20141201/lineups/GBR-0001317-DEFAULT"},{"lineup":"USA-IL57303-X","name":"Comcast Waukegan/Lake Forest Area - Digital","transport":"Cable","location":"Lake Forest","uri":"/20141201/lineups/USA-IL57303-X"},{"lineup":"USA-NY67791-X","name":"Verizon Fios Queens - Digital","transport":"Cable","location":"Fresh Meadows","uri":"/20141201/lineups/USA-NY67791-X"},{"lineup":"USA-OTA-60030","name":"Local Over the Air Broadcast","transport":"Antenna","location":"60030","uri":"/20141201/lineups/USA-OTA-60030"},{"lineup":"USA-WI61859-DEFAULT","name":"DELETED LINEUP","isDeleted":true}]} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/metadata_programs_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/metadata_programs_response.json deleted file mode 100644 index 78166f09a4..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/metadata_programs_response.json +++ /dev/null @@ -1 +0,0 @@ -[{"programID":"SH00712240","data":[{"width":"135","height":"180","uri":"assets/p282288_b_v2_aa.jpg","size":"Sm","aspect":"3x4","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"720","height":"540","uri":"assets/p282288_b_h6_aa.jpg","size":"Lg","aspect":"4x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"960","height":"1440","uri":"assets/p282288_b_v8_aa.jpg","size":"Ms","aspect":"2x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"180","height":"135","uri":"assets/p282288_b_h5_aa.jpg","size":"Sm","aspect":"4x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"}]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/programs_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/programs_response.json deleted file mode 100644 index fe2a944364..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/programs_response.json +++ /dev/null @@ -1 +0,0 @@ -[{"programID":"EP000000060003","titles":[{"title120":"'Allo 'Allo!"}],"eventDetails":{"subType":"Series"},"descriptions":{"description1000":[{"descriptionLanguage":"en","description":"A disguised British Intelligence officer is sent to help the airmen."}]},"originalAirDate":"1985-11-04","genres":["Sitcom"],"episodeTitle150":"The Poloceman Cometh","metadata":[{"Gracenote":{"season":2,"episode":3}}],"cast":[{"personId":"383774","nameId":"392649","name":"Gorden Kaye","role":"Actor","billingOrder":"01"},{"personId":"246840","nameId":"250387","name":"Carmen Silvera","role":"Actor","billingOrder":"02"},{"personId":"376955","nameId":"385830","name":"Rose Hill","role":"Actor","billingOrder":"03"},{"personId":"259773","nameId":"263340","name":"Vicki Michelle","role":"Actor","billingOrder":"04"},{"personId":"353113","nameId":"361987","name":"Kirsten Cooke","role":"Actor","billingOrder":"05"},{"personId":"77787","nameId":"77787","name":"Richard Marner","role":"Actor","billingOrder":"06"},{"personId":"230921","nameId":"234193","name":"Guy Siner","role":"Actor","billingOrder":"07"},{"personId":"374934","nameId":"383809","name":"Kim Hartman","role":"Actor","billingOrder":"08"},{"personId":"369151","nameId":"378026","name":"Richard Gibson","role":"Actor","billingOrder":"09"},{"personId":"343690","nameId":"352564","name":"Arthur Bostrom","role":"Actor","billingOrder":"10"},{"personId":"352557","nameId":"361431","name":"John D. Collins","role":"Actor","billingOrder":"11"},{"personId":"605275","nameId":"627734","name":"Nicholas Frankau","role":"Actor","billingOrder":"12"},{"personId":"373394","nameId":"382269","name":"Jack Haig","role":"Actor","billingOrder":"13"}],"crew":[{"personId":"354407","nameId":"363281","name":"David Croft","role":"Director","billingOrder":"01"},{"personId":"354407","nameId":"363281","name":"David Croft","role":"Writer","billingOrder":"02"},{"personId":"105145","nameId":"105145","name":"Jeremy Lloyd","role":"Writer","billingOrder":"03"}],"showType":"Series","hasImageArtwork":true,"md5":"Jo5NKxoo44xRvBCAq8QT2A"},{"programID":"EP000000510142","titles":[{"title120":"A Different World"}],"eventDetails":{"subType":"Series"},"descriptions":{"description1000":[{"descriptionLanguage":"en","description":"Whitley and Dwayne tell new students about their honeymoon in Los Angeles."}]},"originalAirDate":"1992-09-24","genres":["Sitcom"],"episodeTitle150":"Honeymoon in L.A.","metadata":[{"Gracenote":{"season":6,"episode":1}}],"cast":[{"personId":"700","nameId":"700","name":"Jasmine Guy","role":"Actor","billingOrder":"01"},{"personId":"729","nameId":"729","name":"Kadeem Hardison","role":"Actor","billingOrder":"02"},{"personId":"120","nameId":"120","name":"Darryl M. Bell","role":"Actor","billingOrder":"03"},{"personId":"1729","nameId":"1729","name":"Cree Summer","role":"Actor","billingOrder":"04"},{"personId":"217","nameId":"217","name":"Charnele Brown","role":"Actor","billingOrder":"05"},{"personId":"1811","nameId":"1811","name":"Glynn Turman","role":"Actor","billingOrder":"06"},{"personId":"1232","nameId":"1232","name":"Lou Myers","role":"Actor","billingOrder":"07"},{"personId":"1363","nameId":"1363","name":"Jada Pinkett","role":"Guest Star","billingOrder":"08"},{"personId":"222967","nameId":"225536","name":"Ajai Sanders","role":"Guest Star","billingOrder":"09"},{"personId":"181744","nameId":"183292","name":"Karen Malina White","role":"Guest Star","billingOrder":"10"},{"personId":"305017","nameId":"318897","name":"Patrick Y. Malone","role":"Guest Star","billingOrder":"11"},{"personId":"9841","nameId":"9841","name":"Bumper Robinson","role":"Guest Star","billingOrder":"12"},{"personId":"426422","nameId":"435297","name":"Sister Souljah","role":"Guest Star","billingOrder":"13"},{"personId":"25","nameId":"25","name":"Debbie Allen","role":"Guest Star","billingOrder":"14"},{"personId":"668","nameId":"668","name":"Gilbert Gottfried","role":"Guest Star","billingOrder":"15"}],"showType":"Series","hasImageArtwork":true,"md5":"P5kz0QmCeYxIA+yL0H4DWw"}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_request.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_request.json deleted file mode 100644 index 5ef1bfb1c4..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_request.json +++ /dev/null @@ -1 +0,0 @@ -[{"stationID":"20454","date":["2015-03-13","2015-03-17"]},{"stationID":"10021","date":["2015-03-12","2015-03-13"]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_response.json deleted file mode 100644 index 4a97e55172..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_response.json +++ /dev/null @@ -1 +0,0 @@ -[{"stationID":"20454","programs":[{"programID":"SH005371070000","airDateTime":"2015-03-03T00:00:00Z","duration":1800,"md5":"Sy8HEMBPcuiAx3FBukUhKQ","new":true,"audioProperties":["stereo","cc"],"videoProperties":["hdtv"]},{"programID":"EP000014577244","airDateTime":"2015-03-03T00:30:00Z","duration":1800,"md5":"25DNXVXO192JI7Y9vSW9lQ","new":true,"audioProperties":["stereo","cc"],"videoProperties":["hdtv"]}]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_live_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_live_response.json deleted file mode 100644 index e5fb64a6fa..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_live_response.json +++ /dev/null @@ -1 +0,0 @@ -{"code":0,"message":"OK","serverID":"AWS-SD-web.1","datetime":"2016-08-23T13:55:25Z","token":"f3fca79989cafe7dead71beefedc812b"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_offline_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_offline_response.json deleted file mode 100644 index b66a4ed0c4..0000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_offline_response.json +++ /dev/null @@ -1 +0,0 @@ -{"response":"SERVICE_OFFLINE","code":3000,"serverID":"20141201.web.1","message":"Server offline for maintenance.","datetime":"2015-04-23T00:03:32Z","token":"CAFEDEADBEEFCAFEDEADBEEFCAFEDEADBEEFCAFE"} -- cgit v1.2.3 From c1a3084312fa4fb7796b83640bfe9ad2b5044afa Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 28 Dec 2023 15:15:03 -0500 Subject: Move LiveTv to separate project --- Emby.Server.Implementations/ApplicationHost.cs | 4 - .../Emby.Server.Implementations.csproj | 1 - .../Library/ExclusiveLiveStream.cs | 60 - .../LiveTv/EmbyTV/DirectRecorder.cs | 119 - .../LiveTv/EmbyTV/EmbyTV.cs | 2623 -------------------- .../LiveTv/EmbyTV/EncodedRecorder.cs | 362 --- .../LiveTv/EmbyTV/EntryPoint.cs | 21 - .../LiveTv/EmbyTV/EpgChannelData.cs | 54 - .../LiveTv/EmbyTV/IRecorder.cs | 27 - .../LiveTv/EmbyTV/ItemDataProvider.cs | 163 -- .../LiveTv/EmbyTV/NfoConfigurationExtensions.cs | 19 - .../LiveTv/EmbyTV/RecordingHelper.cs | 83 - .../LiveTv/EmbyTV/SeriesTimerManager.cs | 24 - .../LiveTv/EmbyTV/TimerManager.cs | 181 -- .../LiveTv/Listings/SchedulesDirect.cs | 808 ------ .../Listings/SchedulesDirectDtos/BroadcasterDto.cs | 34 - .../Listings/SchedulesDirectDtos/CaptionDto.cs | 22 - .../LiveTv/Listings/SchedulesDirectDtos/CastDto.cs | 46 - .../Listings/SchedulesDirectDtos/ChannelDto.cs | 30 - .../SchedulesDirectDtos/ContentRatingDto.cs | 22 - .../LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs | 40 - .../LiveTv/Listings/SchedulesDirectDtos/DayDto.cs | 30 - .../SchedulesDirectDtos/Description1000Dto.cs | 22 - .../SchedulesDirectDtos/Description100Dto.cs | 22 - .../SchedulesDirectDtos/DescriptionsProgramDto.cs | 24 - .../SchedulesDirectDtos/EventDetailsDto.cs | 16 - .../Listings/SchedulesDirectDtos/GracenoteDto.cs | 22 - .../Listings/SchedulesDirectDtos/HeadendsDto.cs | 36 - .../Listings/SchedulesDirectDtos/ImageDataDto.cs | 70 - .../Listings/SchedulesDirectDtos/LineupDto.cs | 46 - .../Listings/SchedulesDirectDtos/LineupsDto.cs | 36 - .../LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs | 34 - .../LiveTv/Listings/SchedulesDirectDtos/MapDto.cs | 58 - .../Listings/SchedulesDirectDtos/MetadataDto.cs | 28 - .../SchedulesDirectDtos/MetadataProgramsDto.cs | 16 - .../SchedulesDirectDtos/MetadataScheduleDto.cs | 41 - .../Listings/SchedulesDirectDtos/MovieDto.cs | 30 - .../Listings/SchedulesDirectDtos/MultipartDto.cs | 22 - .../SchedulesDirectDtos/ProgramDetailsDto.cs | 156 -- .../Listings/SchedulesDirectDtos/ProgramDto.cs | 90 - .../SchedulesDirectDtos/QualityRatingDto.cs | 40 - .../Listings/SchedulesDirectDtos/RatingDto.cs | 22 - .../SchedulesDirectDtos/RecommendationDto.cs | 22 - .../RequestScheduleForChannelDto.cs | 24 - .../Listings/SchedulesDirectDtos/ShowImagesDto.cs | 24 - .../Listings/SchedulesDirectDtos/StationDto.cs | 66 - .../Listings/SchedulesDirectDtos/TitleDto.cs | 16 - .../Listings/SchedulesDirectDtos/TokenDto.cs | 47 - .../LiveTv/Listings/XmlTvListingsProvider.cs | 267 -- .../LiveTv/LiveTvConfigurationFactory.cs | 25 - .../LiveTv/LiveTvDtoService.cs | 548 ---- .../LiveTv/LiveTvManager.cs | 2408 ------------------ .../LiveTv/LiveTvMediaSourceProvider.cs | 128 - .../LiveTv/RefreshGuideScheduledTask.cs | 75 - .../LiveTv/TunerHosts/BaseTunerHost.cs | 237 -- .../LiveTv/TunerHosts/HdHomerun/Channels.cs | 23 - .../TunerHosts/HdHomerun/DiscoverResponse.cs | 42 - .../HdHomerun/HdHomerunChannelCommands.cs | 35 - .../LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs | 718 ------ .../TunerHosts/HdHomerun/HdHomerunManager.cs | 351 --- .../TunerHosts/HdHomerun/HdHomerunUdpStream.cs | 218 -- .../HdHomerun/IHdHomerunChannelCommands.cs | 11 - .../HdHomerun/LegacyHdHomerunChannelCommands.cs | 40 - .../LiveTv/TunerHosts/LiveStream.cs | 175 -- .../LiveTv/TunerHosts/M3UTunerHost.cs | 220 -- .../LiveTv/TunerHosts/M3uParser.cs | 326 --- .../LiveTv/TunerHosts/SharedHttpStream.cs | 134 - .../Properties/AssemblyInfo.cs | 1 - Jellyfin.Server/CoreAppHost.cs | 8 + Jellyfin.Server/Jellyfin.Server.csproj | 1 + Jellyfin.sln | 7 + src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs | 118 + src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 2621 +++++++++++++++++++ src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs | 362 +++ src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs | 21 + src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs | 54 + src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs | 27 + src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs | 163 ++ .../EmbyTV/NfoConfigurationExtensions.cs | 19 + src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs | 83 + src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs | 24 + src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs | 181 ++ src/Jellyfin.LiveTv/ExclusiveLiveStream.cs | 61 + src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj | 22 + src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 810 ++++++ .../Listings/SchedulesDirectDtos/BroadcasterDto.cs | 34 + .../Listings/SchedulesDirectDtos/CaptionDto.cs | 22 + .../Listings/SchedulesDirectDtos/CastDto.cs | 46 + .../Listings/SchedulesDirectDtos/ChannelDto.cs | 30 + .../SchedulesDirectDtos/ContentRatingDto.cs | 22 + .../Listings/SchedulesDirectDtos/CrewDto.cs | 40 + .../Listings/SchedulesDirectDtos/DayDto.cs | 30 + .../SchedulesDirectDtos/Description1000Dto.cs | 22 + .../SchedulesDirectDtos/Description100Dto.cs | 22 + .../SchedulesDirectDtos/DescriptionsProgramDto.cs | 24 + .../SchedulesDirectDtos/EventDetailsDto.cs | 16 + .../Listings/SchedulesDirectDtos/GracenoteDto.cs | 22 + .../Listings/SchedulesDirectDtos/HeadendsDto.cs | 36 + .../Listings/SchedulesDirectDtos/ImageDataDto.cs | 70 + .../Listings/SchedulesDirectDtos/LineupDto.cs | 46 + .../Listings/SchedulesDirectDtos/LineupsDto.cs | 36 + .../Listings/SchedulesDirectDtos/LogoDto.cs | 34 + .../Listings/SchedulesDirectDtos/MapDto.cs | 58 + .../Listings/SchedulesDirectDtos/MetadataDto.cs | 28 + .../SchedulesDirectDtos/MetadataProgramsDto.cs | 16 + .../SchedulesDirectDtos/MetadataScheduleDto.cs | 41 + .../Listings/SchedulesDirectDtos/MovieDto.cs | 30 + .../Listings/SchedulesDirectDtos/MultipartDto.cs | 22 + .../SchedulesDirectDtos/ProgramDetailsDto.cs | 156 ++ .../Listings/SchedulesDirectDtos/ProgramDto.cs | 90 + .../SchedulesDirectDtos/QualityRatingDto.cs | 40 + .../Listings/SchedulesDirectDtos/RatingDto.cs | 22 + .../SchedulesDirectDtos/RecommendationDto.cs | 22 + .../RequestScheduleForChannelDto.cs | 24 + .../Listings/SchedulesDirectDtos/ShowImagesDto.cs | 24 + .../Listings/SchedulesDirectDtos/StationDto.cs | 66 + .../Listings/SchedulesDirectDtos/TitleDto.cs | 16 + .../Listings/SchedulesDirectDtos/TokenDto.cs | 47 + .../Listings/XmlTvListingsProvider.cs | 267 ++ src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs | 25 + src/Jellyfin.LiveTv/LiveTvDtoService.cs | 548 ++++ src/Jellyfin.LiveTv/LiveTvManager.cs | 2409 ++++++++++++++++++ src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs | 128 + src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs | 75 + src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs | 237 ++ .../TunerHosts/HdHomerun/Channels.cs | 23 + .../TunerHosts/HdHomerun/DiscoverResponse.cs | 42 + .../HdHomerun/HdHomerunChannelCommands.cs | 35 + .../TunerHosts/HdHomerun/HdHomerunHost.cs | 718 ++++++ .../TunerHosts/HdHomerun/HdHomerunManager.cs | 351 +++ .../TunerHosts/HdHomerun/HdHomerunUdpStream.cs | 219 ++ .../HdHomerun/IHdHomerunChannelCommands.cs | 11 + .../HdHomerun/LegacyHdHomerunChannelCommands.cs | 40 + src/Jellyfin.LiveTv/TunerHosts/LiveStream.cs | 176 ++ src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs | 220 ++ src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs | 326 +++ src/Jellyfin.LiveTv/TunerHosts/SharedHttpStream.cs | 135 + tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs | 2 +- .../Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs | 2 +- .../Jellyfin.LiveTv.Tests.csproj | 2 +- .../Listings/XmlTvListingsProviderTests.cs | 2 +- .../Jellyfin.LiveTv.Tests/RecordingHelperTests.cs | 2 +- .../SchedulesDirectDeserializeTests.cs | 2 +- 143 files changed, 11827 insertions(+), 11791 deletions(-) delete mode 100644 Emby.Server.Implementations/Library/ExclusiveLiveStream.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/NfoConfigurationExtensions.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs delete mode 100644 Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs delete mode 100644 Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs delete mode 100644 Emby.Server.Implementations/LiveTv/LiveTvManager.cs delete mode 100644 Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs delete mode 100644 Emby.Server.Implementations/LiveTv/RefreshGuideScheduledTask.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/Channels.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs create mode 100644 src/Jellyfin.LiveTv/ExclusiveLiveStream.cs create mode 100644 src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CastDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DayDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/StationDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs create mode 100644 src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs create mode 100644 src/Jellyfin.LiveTv/LiveTvDtoService.cs create mode 100644 src/Jellyfin.LiveTv/LiveTvManager.cs create mode 100644 src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs create mode 100644 src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/Channels.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/LiveStream.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/SharedHttpStream.cs diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index f385f6a512..bb565fb2ba 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -25,7 +25,6 @@ using Emby.Server.Implementations.Dto; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; -using Emby.Server.Implementations.LiveTv; using Emby.Server.Implementations.Localization; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.Plugins; @@ -567,9 +566,6 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index b3344bb9ff..34276355a7 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -22,7 +22,6 @@ - diff --git a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs deleted file mode 100644 index b1649afad5..0000000000 --- a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs +++ /dev/null @@ -1,60 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; - -namespace Emby.Server.Implementations.Library -{ - public sealed class ExclusiveLiveStream : ILiveStream - { - private readonly Func _closeFn; - - public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func closeFn) - { - MediaSource = mediaSource; - EnableStreamSharing = false; - _closeFn = closeFn; - ConsumerCount = 1; - UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - } - - public int ConsumerCount { get; set; } - - public string OriginalStreamId { get; set; } - - public string TunerHostId => null; - - public bool EnableStreamSharing { get; set; } - - public MediaSourceInfo MediaSource { get; set; } - - public string UniqueId { get; } - - public Task Close() - { - return _closeFn(); - } - - public Stream GetStream() - { - throw new NotSupportedException(); - } - - public Task Open(CancellationToken openCancellationToken) - { - return Task.CompletedTask; - } - - /// - public void Dispose() - { - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs deleted file mode 100644 index 7df66d3585..0000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs +++ /dev/null @@ -1,119 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Api.Helpers; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Streaming; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public sealed class DirectRecorder : IRecorder - { - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IStreamHelper _streamHelper; - - public DirectRecorder(ILogger logger, IHttpClientFactory httpClientFactory, IStreamHelper streamHelper) - { - _logger = logger; - _httpClientFactory = httpClientFactory; - _streamHelper = streamHelper; - } - - public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) - { - return targetFile; - } - - public Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - if (directStreamProvider is not null) - { - return RecordFromDirectStreamProvider(directStreamProvider, targetFile, duration, onStarted, cancellationToken); - } - - return RecordFromMediaSource(mediaSource, targetFile, duration, onStarted, cancellationToken); - } - - private async Task RecordFromDirectStreamProvider(IDirectStreamProvider directStreamProvider, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - - var output = new FileStream( - targetFile, - FileMode.CreateNew, - FileAccess.Write, - FileShare.Read, - IODefaults.FileStreamBufferSize, - FileOptions.Asynchronous); - - await using (output.ConfigureAwait(false)) - { - onStarted(); - - _logger.LogInformation("Copying recording to file {FilePath}", targetFile); - - // The media source is infinite so we need to handle stopping ourselves - using var durationToken = new CancellationTokenSource(duration); - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - var linkedCancellationToken = cancellationTokenSource.Token; - var fileStream = new ProgressiveFileStream(directStreamProvider.GetStream()); - await using (fileStream.ConfigureAwait(false)) - { - await _streamHelper.CopyToAsync( - fileStream, - output, - IODefaults.CopyToBufferSize, - 1000, - linkedCancellationToken).ConfigureAwait(false); - } - } - - _logger.LogInformation("Recording completed: {FilePath}", targetFile); - } - - private async Task RecordFromMediaSource(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(mediaSource.Path, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - - _logger.LogInformation("Opened recording stream from tuner provider"); - - Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - - var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.CopyToBufferSize, FileOptions.Asynchronous); - await using (output.ConfigureAwait(false)) - { - onStarted(); - - _logger.LogInformation("Copying recording stream to file {0}", targetFile); - - // The media source if infinite so we need to handle stopping ourselves - using var durationToken = new CancellationTokenSource(duration); - using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - cancellationToken = linkedCancellationToken.Token; - - await _streamHelper.CopyUntilCancelled( - await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), - output, - IODefaults.CopyToBufferSize, - cancellationToken).ConfigureAwait(false); - - _logger.LogInformation("Recording completed to file {0}", targetFile); - } - } - - /// - public void Dispose() - { - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs deleted file mode 100644 index e2e0dfb2bb..0000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ /dev/null @@ -1,2623 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using System.Xml; -using Emby.Server.Implementations.Library; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Events; -using Jellyfin.Extensions; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; -using MediaBrowser.Controller; -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 Emby.Server.Implementations.LiveTv.EmbyTV -{ - public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable - { - public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; - - private const int TunerDiscoveryDurationMs = 3000; - - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IServerConfigurationManager _config; - - private readonly ItemDataProvider _seriesTimerProvider; - private readonly TimerManager _timerProvider; - - private readonly LiveTvManager _liveTvManager; - private readonly IFileSystem _fileSystem; - - private readonly ILibraryMonitor _libraryMonitor; - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IStreamHelper _streamHelper; - - private readonly ConcurrentDictionary _activeRecordings = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - private readonly ConcurrentDictionary _epgChannels = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); - - private bool _disposed; - - public EmbyTV( - IStreamHelper streamHelper, - IMediaSourceManager mediaSourceManager, - ILogger logger, - IHttpClientFactory httpClientFactory, - IServerConfigurationManager config, - ILiveTvManager liveTvManager, - IFileSystem fileSystem, - ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor, - IProviderManager providerManager, - IMediaEncoder mediaEncoder) - { - Current = this; - - _logger = logger; - _httpClientFactory = httpClientFactory; - _config = config; - _fileSystem = fileSystem; - _libraryManager = libraryManager; - _libraryMonitor = libraryMonitor; - _providerManager = providerManager; - _mediaEncoder = mediaEncoder; - _liveTvManager = (LiveTvManager)liveTvManager; - _mediaSourceManager = mediaSourceManager; - _streamHelper = streamHelper; - - _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); - _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); - _timerProvider.TimerFired += OnTimerProviderTimerFired; - - _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; - } - - public event EventHandler> TimerCreated; - - public event EventHandler> TimerCancelled; - - public static EmbyTV Current { get; private set; } - - /// - public string Name => "Emby"; - - public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); - - /// - public string HomePageUrl => "https://github.com/jellyfin/jellyfin"; - - private string DefaultRecordingPath => Path.Combine(DataPath, "recordings"); - - private string RecordingPath - { - get - { - var path = GetConfiguration().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(); - - 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 = GetConfiguration(); - - var pathsToRemove = config.MediaLocationsCreated - .Except(recordingFolders.SelectMany(i => i.Locations)) - .ToList(); - - if (pathsAdded.Count > 0 || pathsToRemove.Count > 0) - { - pathsAdded.InsertRange(0, config.MediaLocationsCreated); - config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - _config.SaveConfiguration("livetv", config); - } - - foreach (var path in pathsToRemove) - { - await RemovePathFromLibraryAsync(path).ConfigureAwait(false); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating recording folders"); - } - } - - private async Task RemovePathFromLibraryAsync(string path) - { - _logger.LogDebug("Removing path from library: {0}", path); - - var requiresRefresh = false; - var virtualFolders = _libraryManager.GetVirtualFolders(); - - foreach (var virtualFolder in virtualFolders) - { - if (!virtualFolder.Locations.Contains(path, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (virtualFolder.Locations.Length == 1) - { - // remove entire virtual folder - try - { - await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error removing virtual folder"); - } - } - else - { - try - { - _libraryManager.RemoveMediaPath(virtualFolder.Name, path); - requiresRefresh = true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error removing media path"); - } - } - } - - if (requiresRefresh) - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); - } - } - - public async Task RefreshSeriesTimers(CancellationToken cancellationToken) - { - var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); - - foreach (var timer in seriesTimers) - { - UpdateTimersForSeriesTimer(timer, false, true); - } - } - - public async Task RefreshTimers(CancellationToken cancellationToken) - { - var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false); - - var tempChannelCache = new Dictionary(); - - foreach (var timer in timers) - { - if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id)) - { - OnTimerOutOfDate(timer); - continue; - } - - if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId)) - { - continue; - } - - var program = GetProgramInfoFromCache(timer); - if (program is null) - { - OnTimerOutOfDate(timer); - continue; - } - - CopyProgramInfoToTimerInfo(program, timer, tempChannelCache); - _timerProvider.Update(timer); - } - } - - private void OnTimerOutOfDate(TimerInfo timer) - { - _timerProvider.Delete(timer); - } - - private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) - { - var list = new List(); - - foreach (var hostInstance in _liveTvManager.TunerHosts) - { - try - { - var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); - - list.AddRange(channels); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channels"); - } - } - - foreach (var provider in GetListingProviders()) - { - var enabledChannels = list - .Where(i => IsListingProviderEnabledForTuner(provider.Item2, i.TunerHostId)) - .ToList(); - - if (enabledChannels.Count > 0) - { - try - { - await AddMetadata(provider.Item1, provider.Item2, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false); - } - catch (NotSupportedException) - { - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding metadata"); - } - } - } - - return list; - } - - private async Task AddMetadata( - IListingsProvider provider, - ListingsProviderInfo info, - IEnumerable tunerChannels, - bool enableCache, - CancellationToken cancellationToken) - { - var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false); - - foreach (var tunerChannel in tunerChannels) - { - var epgChannel = GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels); - - if (epgChannel is not null) - { - if (!string.IsNullOrWhiteSpace(epgChannel.Name)) - { - // tunerChannel.Name = epgChannel.Name; - } - - if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl)) - { - tunerChannel.ImageUrl = epgChannel.ImageUrl; - } - } - } - } - - private async Task 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 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 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> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken) - { - var list = new List(); - - foreach (var hostInstance in _liveTvManager.TunerHosts) - { - try - { - var channels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false); - - list.AddRange(channels); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channels"); - } - } - - return list - .Where(i => IsListingProviderEnabledForTuner(listingsProvider, i.TunerHostId)) - .ToList(); - } - - public Task> GetChannelsAsync(CancellationToken cancellationToken) - { - return GetChannelsAsync(false, cancellationToken); - } - - public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) - { - var timers = _timerProvider - .GetAll() - .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - foreach (var timer in timers) - { - CancelTimerInternal(timer.Id, true, true); - } - - var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); - if (remove is not null) - { - _seriesTimerProvider.Delete(remove); - } - - return Task.CompletedTask; - } - - private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation) - { - var timer = _timerProvider.GetTimer(timerId); - if (timer is not null) - { - var statusChanging = timer.Status != RecordingStatus.Cancelled; - timer.Status = RecordingStatus.Cancelled; - - if (isManualCancellation) - { - timer.IsManual = true; - } - - if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled) - { - _timerProvider.Delete(timer); - } - else - { - _timerProvider.AddOrUpdate(timer, false); - } - - if (statusChanging && TimerCancelled is not null) - { - TimerCancelled(this, new GenericEventArgs(timerId)); - } - } - - if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo)) - { - activeRecordingInfo.Timer = timer; - activeRecordingInfo.CancellationTokenSource.Cancel(); - } - } - - public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) - { - CancelTimerInternal(timerId, false, true); - return Task.CompletedTask; - } - - public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task CreateTimer(TimerInfo info, CancellationToken cancellationToken) - { - var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ? - null : - _timerProvider.GetTimerByProgramId(info.ProgramId); - - if (existingTimer is not null) - { - if (existingTimer.Status == RecordingStatus.Cancelled - || existingTimer.Status == RecordingStatus.Completed) - { - existingTimer.Status = RecordingStatus.New; - existingTimer.IsManual = true; - _timerProvider.Update(existingTimer); - return Task.FromResult(existingTimer.Id); - } - - throw new ArgumentException("A scheduled recording already exists for this program."); - } - - info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - - LiveTvProgram programInfo = null; - - if (!string.IsNullOrWhiteSpace(info.ProgramId)) - { - programInfo = GetProgramInfoFromCache(info); - } - - if (programInfo is null) - { - _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId); - programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate); - } - - if (programInfo is not null) - { - CopyProgramInfoToTimerInfo(programInfo, info); - } - - info.IsManual = true; - _timerProvider.Add(info); - - TimerCreated?.Invoke(this, new GenericEventArgs(info)); - - return Task.FromResult(info.Id); - } - - public async Task CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken) - { - info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - - // populate info.seriesID - var program = GetProgramInfoFromCache(info.ProgramId); - - if (program is not null) - { - info.SeriesId = program.ExternalSeriesId; - } - else - { - throw new InvalidOperationException("SeriesId for program not found"); - } - - // If any timers have already been manually created, make sure they don't get cancelled - var existingTimers = (await GetTimersAsync(CancellationToken.None).ConfigureAwait(false)) - .Where(i => - { - if (string.Equals(i.ProgramId, info.ProgramId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.ProgramId)) - { - return true; - } - - if (string.Equals(i.SeriesId, info.SeriesId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.SeriesId)) - { - return true; - } - - return false; - }) - .ToList(); - - _seriesTimerProvider.Add(info); - - foreach (var timer in existingTimers) - { - timer.SeriesTimerId = info.Id; - timer.IsManual = true; - - _timerProvider.AddOrUpdate(timer, false); - } - - UpdateTimersForSeriesTimer(info, true, false); - - return info.Id; - } - - public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) - { - var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); - - if (instance is not null) - { - instance.ChannelId = info.ChannelId; - instance.Days = info.Days; - instance.EndDate = info.EndDate; - instance.IsPostPaddingRequired = info.IsPostPaddingRequired; - instance.IsPrePaddingRequired = info.IsPrePaddingRequired; - instance.PostPaddingSeconds = info.PostPaddingSeconds; - instance.PrePaddingSeconds = info.PrePaddingSeconds; - instance.Priority = info.Priority; - instance.RecordAnyChannel = info.RecordAnyChannel; - instance.RecordAnyTime = info.RecordAnyTime; - instance.RecordNewOnly = info.RecordNewOnly; - instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary; - instance.KeepUpTo = info.KeepUpTo; - instance.KeepUntil = info.KeepUntil; - instance.StartDate = info.StartDate; - - _seriesTimerProvider.Update(instance); - - UpdateTimersForSeriesTimer(instance, true, true); - } - - return Task.CompletedTask; - } - - public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) - { - var existingTimer = _timerProvider.GetTimer(updatedTimer.Id); - - if (existingTimer is null) - { - throw new ResourceNotFoundException(); - } - - // Only update if not currently active - if (!_activeRecordings.TryGetValue(updatedTimer.Id, out _)) - { - existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; - existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; - existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired; - existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired; - - _timerProvider.Update(existingTimer); - } - - return Task.CompletedTask; - } - - private static void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer) - { - // Update the program info but retain the status - existingTimer.ChannelId = updatedTimer.ChannelId; - existingTimer.CommunityRating = updatedTimer.CommunityRating; - existingTimer.EndDate = updatedTimer.EndDate; - existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber; - existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle; - existingTimer.Genres = updatedTimer.Genres; - existingTimer.IsMovie = updatedTimer.IsMovie; - existingTimer.IsSeries = updatedTimer.IsSeries; - existingTimer.Tags = updatedTimer.Tags; - existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries; - existingTimer.IsRepeat = updatedTimer.IsRepeat; - existingTimer.Name = updatedTimer.Name; - existingTimer.OfficialRating = updatedTimer.OfficialRating; - existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate; - existingTimer.Overview = updatedTimer.Overview; - existingTimer.ProductionYear = updatedTimer.ProductionYear; - existingTimer.ProgramId = updatedTimer.ProgramId; - existingTimer.SeasonNumber = updatedTimer.SeasonNumber; - existingTimer.StartDate = updatedTimer.StartDate; - existingTimer.ShowId = updatedTimer.ShowId; - existingTimer.ProviderIds = updatedTimer.ProviderIds; - existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds; - } - - public string GetActiveRecordingPath(string id) - { - if (_activeRecordings.TryGetValue(id, out var info)) - { - return info.Path; - } - - return null; - } - - public ActiveRecordingInfo GetActiveRecordingInfo(string path) - { - if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty) - { - return null; - } - - foreach (var (_, recordingInfo) in _activeRecordings) - { - if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) && !recordingInfo.CancellationTokenSource.IsCancellationRequested) - { - var timer = recordingInfo.Timer; - if (timer.Status != RecordingStatus.InProgress) - { - return null; - } - - return recordingInfo; - } - } - - return null; - } - - public Task> GetTimersAsync(CancellationToken cancellationToken) - { - var excludeStatues = new List - { - RecordingStatus.Completed - }; - - var timers = _timerProvider.GetAll() - .Where(i => !excludeStatues.Contains(i.Status)); - - return Task.FromResult(timers); - } - - public Task GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) - { - var config = GetConfiguration(); - - var defaults = new SeriesTimerInfo() - { - PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0), - PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0), - RecordAnyChannel = false, - RecordAnyTime = true, - RecordNewOnly = true, - - Days = new List - { - DayOfWeek.Sunday, - DayOfWeek.Monday, - DayOfWeek.Tuesday, - DayOfWeek.Wednesday, - DayOfWeek.Thursday, - DayOfWeek.Friday, - DayOfWeek.Saturday - } - }; - - if (program is not null) - { - defaults.SeriesId = program.SeriesId; - defaults.ProgramId = program.Id; - defaults.RecordNewOnly = !program.IsRepeat; - defaults.Name = program.Name; - } - - defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly; - defaults.KeepUntil = KeepUntil.UntilDeleted; - - return Task.FromResult(defaults); - } - - public Task> GetSeriesTimersAsync(CancellationToken cancellationToken) - { - return Task.FromResult((IEnumerable)_seriesTimerProvider.GetAll()); - } - - private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId) - { - if (info.EnableAllTuners) - { - return true; - } - - if (string.IsNullOrWhiteSpace(tunerHostId)) - { - throw new ArgumentNullException(nameof(tunerHostId)); - } - - return info.EnabledTuners.Contains(tunerHostId, StringComparison.OrdinalIgnoreCase); - } - - public async Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) - { - var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false); - var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase)); - - foreach (var provider in GetListingProviders()) - { - if (!IsListingProviderEnabledForTuner(provider.Item2, channel.TunerHostId)) - { - _logger.LogDebug("Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - continue; - } - - _logger.LogDebug("Getting programs for channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - - var epgChannel = await GetEpgChannelFromTunerChannel(provider.Item1, provider.Item2, channel, cancellationToken).ConfigureAwait(false); - - if (epgChannel is null) - { - _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - continue; - } - - List 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(); - } - - private List> GetListingProviders() - { - return GetConfiguration().ListingProviders - .Select(i => - { - var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); - - return provider is null ? null : new Tuple(provider, i); - }) - .Where(i => i is not null) - .ToList(); - } - - public Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public async Task GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List currentLiveStreams, CancellationToken cancellationToken) - { - _logger.LogInformation("Streaming Channel {Id}", channelId); - - var result = string.IsNullOrEmpty(streamId) ? - null : - currentLiveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase)); - - if (result is not null && result.EnableStreamSharing) - { - result.ConsumerCount++; - - _logger.LogInformation("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount); - - return result; - } - - foreach (var hostInstance in _liveTvManager.TunerHosts) - { - try - { - result = await hostInstance.GetChannelStream(channelId, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false); - - var openedMediaSource = result.MediaSource; - - result.OriginalStreamId = streamId; - - _logger.LogInformation("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId); - - return result; - } - catch (FileNotFoundException) - { - } - catch (OperationCanceledException) - { - } - } - - throw new ResourceNotFoundException("Tuner not found."); - } - - public async Task> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(channelId)) - { - throw new ArgumentNullException(nameof(channelId)); - } - - foreach (var hostInstance in _liveTvManager.TunerHosts) - { - try - { - var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false); - - if (sources.Count > 0) - { - return sources; - } - } - catch (NotImplementedException) - { - } - } - - throw new NotImplementedException(); - } - - public Task CloseLiveStream(string id, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public Task RecordLiveStream(string id, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public Task ResetTuner(string id, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - private async void OnTimerProviderTimerFired(object sender, GenericEventArgs e) - { - var timer = e.Argument; - - _logger.LogInformation("Recording timer fired for {0}.", timer.Name); - - try - { - var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds); - - if (recordingEndDate <= DateTime.UtcNow) - { - _logger.LogWarning("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id); - OnTimerOutOfDate(timer); - return; - } - - var activeRecordingInfo = new ActiveRecordingInfo - { - CancellationTokenSource = new CancellationTokenSource(), - Timer = timer, - Id = timer.Id - }; - - if (!_activeRecordings.ContainsKey(timer.Id)) - { - await RecordStream(timer, recordingEndDate, activeRecordingInfo).ConfigureAwait(false); - } - else - { - _logger.LogInformation("Skipping RecordStream because it's already in progress."); - } - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - _logger.LogError(ex, "Error recording stream"); - } - } - - private string GetRecordingPath(TimerInfo timer, RemoteSearchResult metadata, out string seriesPath) - { - var recordPath = RecordingPath; - var config = GetConfiguration(); - seriesPath = null; - - if (timer.IsProgramSeries) - { - var customRecordingPath = config.SeriesRecordingPath; - var allowSubfolder = true; - if (!string.IsNullOrWhiteSpace(customRecordingPath)) - { - allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); - recordPath = customRecordingPath; - } - - if (allowSubfolder && config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Series"); - } - - // trim trailing period from the folder name - var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim(); - - if (metadata is not null && metadata.ProductionYear.HasValue) - { - folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; - } - - // Can't use the year here in the folder name because it is the year of the episode, not the series. - recordPath = Path.Combine(recordPath, folderName); - - seriesPath = recordPath; - - if (timer.SeasonNumber.HasValue) - { - folderName = string.Format( - CultureInfo.InvariantCulture, - "Season {0}", - timer.SeasonNumber.Value); - recordPath = Path.Combine(recordPath, folderName); - } - } - else if (timer.IsMovie) - { - var customRecordingPath = config.MovieRecordingPath; - var allowSubfolder = true; - if (!string.IsNullOrWhiteSpace(customRecordingPath)) - { - allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); - recordPath = customRecordingPath; - } - - if (allowSubfolder && config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Movies"); - } - - var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); - if (timer.ProductionYear.HasValue) - { - folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; - } - - // trim trailing period from the folder name - folderName = folderName.TrimEnd('.').Trim(); - - recordPath = Path.Combine(recordPath, folderName); - } - else if (timer.IsKids) - { - if (config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Kids"); - } - - var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); - if (timer.ProductionYear.HasValue) - { - folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; - } - - // trim trailing period from the folder name - folderName = folderName.TrimEnd('.').Trim(); - - recordPath = Path.Combine(recordPath, folderName); - } - else if (timer.IsSports) - { - if (config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Sports"); - } - - recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); - } - else - { - if (config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Other"); - } - - recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); - } - - var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts"; - - return Path.Combine(recordPath, recordingFileName); - } - - private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo) - { - ArgumentNullException.ThrowIfNull(timer); - - LiveTvProgram programInfo = null; - - if (!string.IsNullOrWhiteSpace(timer.ProgramId)) - { - programInfo = GetProgramInfoFromCache(timer); - } - - if (programInfo is null) - { - _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); - programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); - } - - if (programInfo is not null) - { - CopyProgramInfoToTimerInfo(programInfo, timer); - } - - var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); - var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); - - var channelItem = _liveTvManager.GetLiveTvChannel(timer, this); - - string liveStreamId = null; - RecordingStatus recordingStatus; - try - { - var allMediaSources = await _mediaSourceManager.GetPlaybackMediaSources(channelItem, null, true, false, CancellationToken.None).ConfigureAwait(false); - - var mediaStreamInfo = allMediaSources[0]; - IDirectStreamProvider directStreamProvider = null; - - if (mediaStreamInfo.RequiresOpening) - { - var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal( - new LiveStreamRequest - { - ItemId = channelItem.Id, - OpenToken = mediaStreamInfo.OpenToken - }, - CancellationToken.None).ConfigureAwait(false); - - mediaStreamInfo = liveStreamResponse.Item1.MediaSource; - liveStreamId = mediaStreamInfo.LiveStreamId; - directStreamProvider = liveStreamResponse.Item2; - } - - using var recorder = GetRecorder(mediaStreamInfo); - - recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath); - recordPath = EnsureFileUnique(recordPath, timer.Id); - - _libraryMonitor.ReportFileSystemChangeBeginning(recordPath); - - var duration = recordingEndDate - DateTime.UtcNow; - - _logger.LogInformation("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); - - _logger.LogInformation("Writing file to: {Path}", recordPath); - - Action onStarted = async () => - { - activeRecordingInfo.Path = recordPath; - - _activeRecordings.TryAdd(timer.Id, activeRecordingInfo); - - timer.Status = RecordingStatus.InProgress; - _timerProvider.AddOrUpdate(timer, false); - - await SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false); - - await CreateRecordingFolders().ConfigureAwait(false); - - TriggerRefresh(recordPath); - await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false); - }; - - await recorder.Record(directStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); - - recordingStatus = RecordingStatus.Completed; - _logger.LogInformation("Recording completed: {RecordPath}", recordPath); - } - catch (OperationCanceledException) - { - _logger.LogInformation("Recording stopped: {RecordPath}", recordPath); - recordingStatus = RecordingStatus.Completed; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error recording to {RecordPath}", recordPath); - recordingStatus = RecordingStatus.Error; - } - - if (!string.IsNullOrWhiteSpace(liveStreamId)) - { - try - { - await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error closing live stream"); - } - } - - DeleteFileIfEmpty(recordPath); - - TriggerRefresh(recordPath); - _libraryMonitor.ReportFileSystemChangeComplete(recordPath, false); - - _activeRecordings.TryRemove(timer.Id, out _); - - if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) - { - const int RetryIntervalSeconds = 60; - _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds); - - timer.Status = RecordingStatus.New; - timer.PrePaddingSeconds = 0; - timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds); - timer.RetryCount++; - _timerProvider.AddOrUpdate(timer); - } - else if (File.Exists(recordPath)) - { - timer.RecordingPath = recordPath; - timer.Status = RecordingStatus.Completed; - _timerProvider.AddOrUpdate(timer, false); - OnSuccessfulRecording(timer, recordPath); - } - else - { - _timerProvider.Delete(timer); - } - } - - private async Task FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken) - { - if (timer.IsSeries) - { - if (timer.SeriesProviderIds.Count == 0) - { - return null; - } - - var query = new RemoteSearchQuery() - { - SearchInfo = new SeriesInfo - { - ProviderIds = timer.SeriesProviderIds, - Name = timer.Name, - MetadataCountryCode = _config.Configuration.MetadataCountryCode, - MetadataLanguage = _config.Configuration.PreferredMetadataLanguage - } - }; - - var results = await _providerManager.GetRemoteSearchResults(query, cancellationToken).ConfigureAwait(false); - - return results.FirstOrDefault(); - } - - return null; - } - - private void DeleteFileIfEmpty(string path) - { - var file = _fileSystem.GetFileInfo(path); - - if (file.Exists && file.Length == 0) - { - try - { - _fileSystem.DeleteFile(path); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path); - } - } - } - - private void TriggerRefresh(string path) - { - _logger.LogInformation("Triggering refresh on {Path}", path); - - var item = GetAffectedBaseItem(Path.GetDirectoryName(path)); - - if (item is not null) - { - _logger.LogInformation("Refreshing recording parent {Path}", item.Path); - - _providerManager.QueueRefresh( - item.Id, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - RefreshPaths = new string[] - { - path, - Path.GetDirectoryName(path), - Path.GetDirectoryName(Path.GetDirectoryName(path)) - } - }, - RefreshPriority.High); - } - } - - private BaseItem GetAffectedBaseItem(string path) - { - BaseItem item = null; - - var parentPath = Path.GetDirectoryName(path); - - while (item is null && !string.IsNullOrEmpty(path)) - { - item = _libraryManager.FindByPath(path, null); - - path = Path.GetDirectoryName(path); - } - - if (item is not null) - { - if (item.GetType() == typeof(Folder) && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase)) - { - var parentItem = item.GetParent(); - if (parentItem is not null && parentItem is not AggregateFolder) - { - item = parentItem; - } - } - } - - return item; - } - - private async Task EnforceKeepUpTo(TimerInfo timer, string seriesPath) - { - if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)) - { - return; - } - - if (string.IsNullOrWhiteSpace(seriesPath)) - { - return; - } - - var seriesTimerId = timer.SeriesTimerId; - var seriesTimer = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); - - if (seriesTimer is null || seriesTimer.KeepUpTo <= 0) - { - return; - } - - if (_disposed) - { - return; - } - - await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false); - - try - { - if (_disposed) - { - return; - } - - var timersToDelete = _timerProvider.GetAll() - .Where(i => i.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(i.RecordingPath)) - .Where(i => string.Equals(i.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(i => i.EndDate) - .Where(i => File.Exists(i.RecordingPath)) - .Skip(seriesTimer.KeepUpTo - 1) - .ToList(); - - DeleteLibraryItemsForTimers(timersToDelete); - - if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries) - { - return; - } - - var episodesToDelete = librarySeries.GetItemList( - new InternalItemsQuery - { - OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, - IsVirtualItem = false, - IsFolder = false, - Recursive = true, - DtoOptions = new DtoOptions(true) - }) - .Where(i => i.IsFileProtocol && File.Exists(i.Path)) - .Skip(seriesTimer.KeepUpTo - 1) - .ToList(); - - foreach (var item in episodesToDelete) - { - try - { - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = true - }, - true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting item"); - } - } - } - finally - { - _recordingDeleteSemaphore.Release(); - } - } - - private void DeleteLibraryItemsForTimers(List 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 = GetConfiguration(); - 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 = GetConfiguration(); - - 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(""", "'", 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.Equals(default) ? new List() : _libraryManager.GetPeople(item); - - var directors = people - .Where(i => i.IsType(PersonKind.Director)) - .Select(i => i.Name) - .ToList(); - - foreach (var person in directors) - { - await writer.WriteElementStringAsync(null, "director", null, person).ConfigureAwait(false); - } - - var writers = people - .Where(i => i.IsType(PersonKind.Writer)) - .Select(i => i.Name) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - foreach (var person in writers) - { - await writer.WriteElementStringAsync(null, "writer", null, person).ConfigureAwait(false); - } - - foreach (var person in writers) - { - await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false); - } - - var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection); - - if (!string.IsNullOrEmpty(tmdbCollection)) - { - await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false); - } - - var imdb = item.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdb)) - { - if (!isSeriesEpisode) - { - await writer.WriteElementStringAsync(null, "id", null, imdb).ConfigureAwait(false); - } - - await writer.WriteElementStringAsync(null, "imdbid", null, imdb).ConfigureAwait(false); - - // No need to lock if we have identified the content already - lockData = false; - } - - var tvdb = item.GetProviderId(MetadataProvider.Tvdb); - if (!string.IsNullOrEmpty(tvdb)) - { - await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false); - - // No need to lock if we have identified the content already - lockData = false; - } - - var tmdb = item.GetProviderId(MetadataProvider.Tmdb); - if (!string.IsNullOrEmpty(tmdb)) - { - await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false); - - // No need to lock if we have identified the content already - lockData = false; - } - - if (lockData) - { - await writer.WriteElementStringAsync(null, "lockdata", null, "true").ConfigureAwait(false); - } - - if (item.CriticRating.HasValue) - { - await writer.WriteElementStringAsync(null, "criticrating", null, item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - - if (!string.IsNullOrWhiteSpace(item.Tagline)) - { - await writer.WriteElementStringAsync(null, "tagline", null, item.Tagline).ConfigureAwait(false); - } - - foreach (var studio in item.Studios) - { - await writer.WriteElementStringAsync(null, "studio", null, studio).ConfigureAwait(false); - } - - await writer.WriteEndElementAsync().ConfigureAwait(false); - await writer.WriteEndDocumentAsync().ConfigureAwait(false); - } - } - } - - private LiveTvProgram GetProgramInfoFromCache(string programId) - { - var query = new InternalItemsQuery - { - ItemIds = new[] { _liveTvManager.GetInternalProgramId(programId) }, - Limit = 1, - DtoOptions = new DtoOptions() - }; - - return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); - } - - private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer) - { - return GetProgramInfoFromCache(timer.ProgramId); - } - - private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc) - { - var query = new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - Limit = 1, - DtoOptions = new DtoOptions(true) - { - EnableImages = false - }, - MinStartDate = startDateUtc.AddMinutes(-3), - MaxStartDate = startDateUtc.AddMinutes(3), - OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) } - }; - - if (!string.IsNullOrWhiteSpace(channelId)) - { - query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, channelId) }; - } - - return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); - } - - private LiveTvOptions GetConfiguration() - { - return _config.GetConfiguration("livetv"); - } - - private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer) - { - if (timer.IsManual) - { - return false; - } - - if (!seriesTimer.RecordAnyTime - && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks) - { - return true; - } - - if (seriesTimer.RecordNewOnly && timer.IsRepeat) - { - return true; - } - - if (!seriesTimer.RecordAnyChannel - && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer); - } - - private void HandleDuplicateShowIds(List timers) - { - // sort showings by HD channels first, then by startDate, record earliest showing possible - foreach (var timer in timers.OrderByDescending(t => _liveTvManager.GetLiveTvChannel(t, this).IsHD).ThenBy(t => t.StartDate).Skip(1)) - { - timer.Status = RecordingStatus.Cancelled; - _timerProvider.Update(timer); - } - } - - private void SearchForDuplicateShowIds(IEnumerable timers) - { - var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList(); - - foreach (var group in groups) - { - if (string.IsNullOrWhiteSpace(group.Key)) - { - continue; - } - - var groupTimers = group.ToList(); - - if (groupTimers.Count < 2) - { - continue; - } - - // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856 - if (group.Key.EndsWith("0000", StringComparison.Ordinal)) - { - continue; - } - - HandleDuplicateShowIds(groupTimers); - } - } - - private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers) - { - var allTimers = GetTimersForSeries(seriesTimer).ToList(); - - var enabledTimersForSeries = new List(); - foreach (var timer in allTimers) - { - var existingTimer = _timerProvider.GetTimer(timer.Id) - ?? (string.IsNullOrWhiteSpace(timer.ProgramId) - ? null - : _timerProvider.GetTimerByProgramId(timer.ProgramId)); - - if (existingTimer is null) - { - if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) - { - timer.Status = RecordingStatus.Cancelled; - } - else - { - enabledTimersForSeries.Add(timer); - } - - _timerProvider.Add(timer); - - TimerCreated?.Invoke(this, new GenericEventArgs(timer)); - } - - // Only update if not currently active - test both new timer and existing in case Id's are different - // Id's could be different if the timer was created manually prior to series timer creation - else if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _)) - { - UpdateExistingTimerWithNewMetadata(existingTimer, timer); - - // Needed by ShouldCancelTimerForSeriesTimer - timer.IsManual = existingTimer.IsManual; - - if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) - { - existingTimer.Status = RecordingStatus.Cancelled; - } - else if (!existingTimer.IsManual) - { - existingTimer.Status = RecordingStatus.New; - } - - if (existingTimer.Status != RecordingStatus.Cancelled) - { - enabledTimersForSeries.Add(existingTimer); - } - - if (updateTimerSettings) - { - existingTimer.KeepUntil = seriesTimer.KeepUntil; - existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; - existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; - existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; - existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; - existingTimer.Priority = seriesTimer.Priority; - existingTimer.SeriesTimerId = seriesTimer.Id; - } - - existingTimer.SeriesTimerId = seriesTimer.Id; - _timerProvider.Update(existingTimer); - } - } - - SearchForDuplicateShowIds(enabledTimersForSeries); - - if (deleteInvalidTimers) - { - var allTimerIds = allTimers - .Select(i => i.Id) - .ToList(); - - var deleteStatuses = new[] - { - RecordingStatus.New - }; - - var deletes = _timerProvider.GetAll() - .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) - .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) - .Where(i => deleteStatuses.Contains(i.Status)) - .ToList(); - - foreach (var timer in deletes) - { - CancelTimerInternal(timer.Id, false, false); - } - } - } - - private IEnumerable GetTimersForSeries(SeriesTimerInfo seriesTimer) - { - ArgumentNullException.ThrowIfNull(seriesTimer); - - var query = new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ExternalSeriesId = seriesTimer.SeriesId, - DtoOptions = new DtoOptions(true) - { - EnableImages = false - }, - MinEndDate = DateTime.UtcNow - }; - - if (string.IsNullOrEmpty(seriesTimer.SeriesId)) - { - query.Name = seriesTimer.Name; - } - - if (!seriesTimer.RecordAnyChannel) - { - query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, seriesTimer.ChannelId) }; - } - - var tempChannelCache = new Dictionary(); - - return _libraryManager.GetItemList(query).Cast().Select(i => CreateTimer(i, seriesTimer, tempChannelCache)); - } - - private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary tempChannelCache) - { - string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId; - - if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.Equals(default)) - { - if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel)) - { - channel = _libraryManager.GetItemList( - new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, - ItemIds = new[] { parent.ChannelId }, - DtoOptions = new DtoOptions() - }).FirstOrDefault() as LiveTvChannel; - - if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) - { - tempChannelCache[parent.ChannelId] = channel; - } - } - - if (channel is not null || tempChannelCache.TryGetValue(parent.ChannelId, out channel)) - { - channelId = channel.ExternalId; - } - } - - var timer = new TimerInfo - { - ChannelId = channelId, - Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture), - StartDate = parent.StartDate, - EndDate = parent.EndDate.Value, - ProgramId = parent.ExternalId, - PrePaddingSeconds = seriesTimer.PrePaddingSeconds, - PostPaddingSeconds = seriesTimer.PostPaddingSeconds, - IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired, - IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired, - KeepUntil = seriesTimer.KeepUntil, - Priority = seriesTimer.Priority, - Name = parent.Name, - Overview = parent.Overview, - SeriesId = parent.ExternalSeriesId, - SeriesTimerId = seriesTimer.Id, - ShowId = parent.ShowId - }; - - CopyProgramInfoToTimerInfo(parent, timer, tempChannelCache); - - return timer; - } - - private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo) - { - var tempChannelCache = new Dictionary(); - CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache); - } - - private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary tempChannelCache) - { - string channelId = null; - - if (!programInfo.ChannelId.Equals(default)) - { - if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel)) - { - channel = _libraryManager.GetItemList( - new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, - ItemIds = new[] { programInfo.ChannelId }, - DtoOptions = new DtoOptions() - }).FirstOrDefault() as LiveTvChannel; - - if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) - { - tempChannelCache[programInfo.ChannelId] = channel; - } - } - - if (channel is not null || tempChannelCache.TryGetValue(programInfo.ChannelId, out channel)) - { - channelId = channel.ExternalId; - } - } - - timerInfo.Name = programInfo.Name; - timerInfo.StartDate = programInfo.StartDate; - timerInfo.EndDate = programInfo.EndDate.Value; - - if (!string.IsNullOrWhiteSpace(channelId)) - { - timerInfo.ChannelId = channelId; - } - - timerInfo.SeasonNumber = programInfo.ParentIndexNumber; - timerInfo.EpisodeNumber = programInfo.IndexNumber; - timerInfo.IsMovie = programInfo.IsMovie; - timerInfo.ProductionYear = programInfo.ProductionYear; - timerInfo.EpisodeTitle = programInfo.EpisodeTitle; - timerInfo.OriginalAirDate = programInfo.PremiereDate; - timerInfo.IsProgramSeries = programInfo.IsSeries; - - timerInfo.IsSeries = programInfo.IsSeries; - - timerInfo.CommunityRating = programInfo.CommunityRating; - timerInfo.Overview = programInfo.Overview; - timerInfo.OfficialRating = programInfo.OfficialRating; - timerInfo.IsRepeat = programInfo.IsRepeat; - timerInfo.SeriesId = programInfo.ExternalSeriesId; - timerInfo.ProviderIds = programInfo.ProviderIds; - timerInfo.Tags = programInfo.Tags; - - var seriesProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var providerId in timerInfo.ProviderIds) - { - const string Search = "Series"; - if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase)) - { - seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value; - } - } - - timerInfo.SeriesProviderIds = seriesProviderIds; - } - - private bool IsProgramAlreadyInLibrary(TimerInfo program) - { - if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle)) - { - var seriesIds = _libraryManager.GetItemIds( - new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Series }, - Name = program.Name - }).ToArray(); - - if (seriesIds.Length == 0) - { - return false; - } - - if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) - { - var result = _libraryManager.GetItemIds(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Episode }, - ParentIndexNumber = program.SeasonNumber.Value, - IndexNumber = program.EpisodeNumber.Value, - AncestorIds = seriesIds, - IsVirtualItem = false, - Limit = 1 - }); - - if (result.Count > 0) - { - return true; - } - } - } - - return false; - } - - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _recordingDeleteSemaphore.Dispose(); - - foreach (var pair in _activeRecordings.ToList()) - { - pair.Value.CancellationTokenSource.Cancel(); - } - - _disposed = true; - } - - public IEnumerable GetRecordingFolders() - { - var defaultFolder = RecordingPath; - var defaultName = "Recordings"; - - if (Directory.Exists(defaultFolder)) - { - yield return new VirtualFolderInfo - { - Locations = new string[] { defaultFolder }, - Name = defaultName - }; - } - - var customPath = GetConfiguration().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 = GetConfiguration().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 - }; - } - } - - public async Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) - { - var list = new List(); - - var configuredDeviceIds = GetConfiguration().TunerHosts - .Where(i => !string.IsNullOrWhiteSpace(i.DeviceId)) - .Select(i => i.DeviceId) - .ToList(); - - foreach (var host in _liveTvManager.TunerHosts) - { - var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); - - if (newDevicesOnly) - { - discoveredDevices = discoveredDevices.Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - - list.AddRange(discoveredDevices); - } - - return list; - } - - public async Task ScanForTunerDeviceChanges(CancellationToken cancellationToken) - { - foreach (var host in _liveTvManager.TunerHosts) - { - await ScanForTunerDeviceChanges(host, cancellationToken).ConfigureAwait(false); - } - } - - private async Task ScanForTunerDeviceChanges(ITunerHost host, CancellationToken cancellationToken) - { - var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); - - var configuredDevices = GetConfiguration().TunerHosts - .Where(i => string.Equals(i.Type, host.Type, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - foreach (var device in discoveredDevices) - { - var configuredDevice = configuredDevices.FirstOrDefault(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase)); - - if (configuredDevice is not null && !string.Equals(device.Url, configuredDevice.Url, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Tuner url has changed from {PreviousUrl} to {NewUrl}", configuredDevice.Url, device.Url); - - configuredDevice.Url = device.Url; - await _liveTvManager.SaveTunerHost(configuredDevice).ConfigureAwait(false); - } - } - } - - private async Task> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) - { - try - { - var discoveredDevices = await host.DiscoverDevices(discoveryDurationMs, cancellationToken).ConfigureAwait(false); - - foreach (var device in discoveredDevices) - { - _logger.LogInformation("Discovered tuner device {0} at {1}", host.Name, device.Url); - } - - return discoveredDevices; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error discovering tuner devices"); - - return new List(); - } - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs deleted file mode 100644 index 9a9fd02739..0000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs +++ /dev/null @@ -1,362 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Extensions; -using Jellyfin.Extensions.Json; -using MediaBrowser.Common; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public class EncodedRecorder : IRecorder - { - private readonly ILogger _logger; - private readonly IMediaEncoder _mediaEncoder; - private readonly IServerApplicationPaths _appPaths; - private readonly TaskCompletionSource _taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private bool _hasExited; - private FileStream _logFileStream; - private string _targetPath; - private Process _process; - private bool _disposed; - - public EncodedRecorder( - ILogger logger, - IMediaEncoder mediaEncoder, - IServerApplicationPaths appPaths, - IServerConfigurationManager serverConfigurationManager) - { - _logger = logger; - _mediaEncoder = mediaEncoder; - _appPaths = appPaths; - _serverConfigurationManager = serverConfigurationManager; - } - - private static bool CopySubtitles => false; - - public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) - { - return Path.ChangeExtension(targetFile, ".ts"); - } - - public async Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - // The media source is infinite so we need to handle stopping ourselves - using var durationToken = new CancellationTokenSource(duration); - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - - await RecordFromFile(mediaSource, mediaSource.Path, targetFile, onStarted, cancellationTokenSource.Token).ConfigureAwait(false); - - _logger.LogInformation("Recording completed to file {Path}", targetFile); - } - - private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, Action onStarted, CancellationToken cancellationToken) - { - _targetPath = targetFile; - Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); - - var processStartInfo = new ProcessStartInfo - { - CreateNoWindow = true, - UseShellExecute = false, - - RedirectStandardError = true, - RedirectStandardInput = true, - - FileName = _mediaEncoder.EncoderPath, - Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile), - - WindowStyle = ProcessWindowStyle.Hidden, - ErrorDialog = false - }; - - _logger.LogInformation("{Filename} {Arguments}", processStartInfo.FileName, processStartInfo.Arguments); - - var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt"); - Directory.CreateDirectory(Path.GetDirectoryName(logFilePath)); - - // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. - _logFileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - - await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureAwait(false); - await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + processStartInfo.FileName + " " + processStartInfo.Arguments + Environment.NewLine + Environment.NewLine), cancellationToken).ConfigureAwait(false); - - _process = new Process - { - StartInfo = processStartInfo, - EnableRaisingEvents = true - }; - _process.Exited += (_, _) => OnFfMpegProcessExited(_process); - - _process.Start(); - - cancellationToken.Register(Stop); - - onStarted(); - - // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback - _ = StartStreamingLog(_process.StandardError.BaseStream, _logFileStream); - - _logger.LogInformation("ffmpeg recording process started for {Path}", _targetPath); - - // Block until ffmpeg exits - await _taskCompletionSource.Task.ConfigureAwait(false); - } - - private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile) - { - string videoArgs; - if (EncodeVideo(mediaSource)) - { - const int MaxBitrate = 25000000; - videoArgs = string.Format( - CultureInfo.InvariantCulture, - "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41", - GetOutputSizeParam(), - MaxBitrate); - } - else - { - videoArgs = "-codec:v:0 copy"; - } - - videoArgs += " -fflags +genpts"; - - var flags = new List(); - if (mediaSource.IgnoreDts) - { - flags.Add("+igndts"); - } - - if (mediaSource.IgnoreIndex) - { - flags.Add("+ignidx"); - } - - if (mediaSource.GenPtsInput) - { - flags.Add("+genpts"); - } - - var inputModifier = "-async 1 -vsync -1"; - - if (flags.Count > 0) - { - inputModifier += " -fflags " + string.Join(string.Empty, flags); - } - - if (mediaSource.ReadAtNativeFramerate) - { - inputModifier += " -re"; - } - - if (mediaSource.RequiresLooping) - { - inputModifier += " -stream_loop -1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2"; - } - - var analyzeDurationSeconds = 5; - var analyzeDuration = " -analyzeduration " + - (analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture); - inputModifier += analyzeDuration; - - var subtitleArgs = CopySubtitles ? " -codec:s copy" : " -sn"; - - // var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase) ? - // " -f mp4 -movflags frag_keyframe+empty_moov" : - // string.Empty; - - var outputParam = string.Empty; - - var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null); - var commandLineArgs = string.Format( - CultureInfo.InvariantCulture, - "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", - inputTempFile, - targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename - videoArgs, - GetAudioArgs(mediaSource), - subtitleArgs, - outputParam, - threads); - - return inputModifier + " " + commandLineArgs; - } - - private static string GetAudioArgs(MediaSourceInfo mediaSource) - { - return "-codec:a:0 copy"; - - // var audioChannels = 2; - // var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); - // if (audioStream is not null) - // { - // audioChannels = audioStream.Channels ?? audioChannels; - // } - // return "-codec:a:0 aac -strict experimental -ab 320000"; - } - - private static bool EncodeVideo(MediaSourceInfo mediaSource) - { - return false; - } - - protected string GetOutputSizeParam() - => "-vf \"yadif=0:-1:0\""; - - private void Stop() - { - if (!_hasExited) - { - try - { - _logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath); - - _process.StandardInput.WriteLine("q"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath); - } - - if (_hasExited) - { - return; - } - - try - { - _logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath); - - if (_process.WaitForExit(10000)) - { - return; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath); - } - - if (_hasExited) - { - return; - } - - try - { - _logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath); - - _process.Kill(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath); - } - } - } - - /// - /// Processes the exited. - /// - private void OnFfMpegProcessExited(Process process) - { - using (process) - { - _hasExited = true; - - _logFileStream?.Dispose(); - _logFileStream = null; - - var exitCode = process.ExitCode; - - _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath); - - if (exitCode == 0) - { - _taskCompletionSource.TrySetResult(true); - } - else - { - _taskCompletionSource.TrySetException( - new FfmpegException( - string.Format( - CultureInfo.InvariantCulture, - "Recording for {0} failed. Exit code {1}", - _targetPath, - exitCode))); - } - } - } - - private async Task StartStreamingLog(Stream source, FileStream target) - { - try - { - using (var reader = new StreamReader(source)) - { - await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) - { - var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); - - await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false); - await target.FlushAsync().ConfigureAwait(false); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error reading ffmpeg recording log"); - } - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and optionally managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _logFileStream?.Dispose(); - _process?.Dispose(); - } - - _logFileStream = null; - _process = null; - - _disposed = true; - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs deleted file mode 100644 index a2ec2df375..0000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -#pragma warning disable CS1591 - -using System.Threading.Tasks; -using MediaBrowser.Controller.Plugins; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public sealed class EntryPoint : IServerEntryPoint - { - /// - public Task RunAsync() - { - return EmbyTV.Current.Start(); - } - - /// - public void Dispose() - { - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs deleted file mode 100644 index 20a8213a77..0000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs +++ /dev/null @@ -1,54 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.LiveTv; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - internal class EpgChannelData - { - private readonly Dictionary _channelsById; - - private readonly Dictionary _channelsByNumber; - - private readonly Dictionary _channelsByName; - - public EpgChannelData(IEnumerable channels) - { - _channelsById = new Dictionary(StringComparer.OrdinalIgnoreCase); - _channelsByNumber = new Dictionary(StringComparer.OrdinalIgnoreCase); - _channelsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var channel in channels) - { - _channelsById[channel.Id] = channel; - - if (!string.IsNullOrEmpty(channel.Number)) - { - _channelsByNumber[channel.Number] = channel; - } - - var normalizedName = NormalizeName(channel.Name ?? string.Empty); - if (!string.IsNullOrWhiteSpace(normalizedName)) - { - _channelsByName[normalizedName] = channel; - } - } - } - - public ChannelInfo? GetChannelById(string id) - => _channelsById.GetValueOrDefault(id); - - public ChannelInfo? GetChannelByNumber(string number) - => _channelsByNumber.GetValueOrDefault(number); - - public ChannelInfo? GetChannelByName(string name) - => _channelsByName.GetValueOrDefault(name); - - public static string NormalizeName(string value) - { - return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs deleted file mode 100644 index de14d6d086..0000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs +++ /dev/null @@ -1,27 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public interface IRecorder : IDisposable - { - /// - /// Records the specified media source. - /// - /// The direct stream provider, or null. - /// The media source. - /// The target file. - /// The duration to record. - /// An action to perform when recording starts. - /// The cancellation token. - /// A that represents the recording operation. - Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken); - - string GetOutputPath(MediaSourceInfo mediaSource, string targetFile); - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs deleted file mode 100644 index d5a6feb470..0000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs +++ /dev/null @@ -1,163 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Text.Json; -using Jellyfin.Extensions.Json; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public class ItemDataProvider - where T : class - { - private readonly string _dataPath; - private readonly object _fileDataLock = new object(); - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private T[]? _items; - - public ItemDataProvider( - ILogger logger, - string dataPath, - Func equalityComparer) - { - Logger = logger; - _dataPath = dataPath; - EqualityComparer = equalityComparer; - } - - protected ILogger Logger { get; } - - protected Func EqualityComparer { get; } - - [MemberNotNull(nameof(_items))] - private void EnsureLoaded() - { - if (_items is not null) - { - return; - } - - if (File.Exists(_dataPath)) - { - Logger.LogInformation("Loading live tv data from {Path}", _dataPath); - - try - { - var bytes = File.ReadAllBytes(_dataPath); - _items = JsonSerializer.Deserialize(bytes, _jsonOptions); - if (_items is null) - { - Logger.LogError("Error deserializing {Path}, data was null", _dataPath); - _items = Array.Empty(); - } - - return; - } - catch (JsonException ex) - { - Logger.LogError(ex, "Error deserializing {Path}", _dataPath); - } - } - - _items = Array.Empty(); - } - - private void SaveList() - { - Directory.CreateDirectory(Path.GetDirectoryName(_dataPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(_dataPath))); - var jsonString = JsonSerializer.Serialize(_items, _jsonOptions); - File.WriteAllText(_dataPath, jsonString); - } - - public IReadOnlyList GetAll() - { - lock (_fileDataLock) - { - EnsureLoaded(); - return (T[])_items.Clone(); - } - } - - public virtual void Update(T item) - { - ArgumentNullException.ThrowIfNull(item); - - lock (_fileDataLock) - { - EnsureLoaded(); - - var index = Array.FindIndex(_items, i => EqualityComparer(i, item)); - if (index == -1) - { - throw new ArgumentException("item not found"); - } - - _items[index] = item; - - SaveList(); - } - } - - public virtual void Add(T item) - { - ArgumentNullException.ThrowIfNull(item); - - lock (_fileDataLock) - { - EnsureLoaded(); - - if (_items.Any(i => EqualityComparer(i, item))) - { - throw new ArgumentException("item already exists", nameof(item)); - } - - int oldLen = _items.Length; - var newList = new T[oldLen + 1]; - _items.CopyTo(newList, 0); - newList[oldLen] = item; - _items = newList; - - SaveList(); - } - } - - public virtual void AddOrUpdate(T item) - { - lock (_fileDataLock) - { - EnsureLoaded(); - - int index = Array.FindIndex(_items, i => EqualityComparer(i, item)); - if (index == -1) - { - int oldLen = _items.Length; - var newList = new T[oldLen + 1]; - _items.CopyTo(newList, 0); - newList[oldLen] = item; - _items = newList; - } - else - { - _items[index] = item; - } - - SaveList(); - } - } - - public virtual void Delete(T item) - { - lock (_fileDataLock) - { - EnsureLoaded(); - _items = _items.Where(i => !EqualityComparer(i, item)).ToArray(); - - SaveList(); - } - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/NfoConfigurationExtensions.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/NfoConfigurationExtensions.cs deleted file mode 100644 index 83f5e84131..0000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/NfoConfigurationExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Configuration; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - /// - /// Class containing extension methods for working with the nfo configuration. - /// - public static class NfoConfigurationExtensions - { - /// - /// Gets the nfo configuration. - /// - /// The configuration manager. - /// The nfo configuration. - public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager) - => configurationManager.GetConfiguration("xbmcmetadata"); - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs deleted file mode 100644 index 7bbeae866a..0000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs +++ /dev/null @@ -1,83 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.Text; -using MediaBrowser.Controller.LiveTv; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - internal static class RecordingHelper - { - public static DateTime GetStartTime(TimerInfo timer) - { - return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds); - } - - public static string GetRecordingName(TimerInfo info) - { - var name = info.Name; - - if (info.IsProgramSeries) - { - var addHyphen = true; - - if (info.SeasonNumber.HasValue && info.EpisodeNumber.HasValue) - { - name += string.Format( - CultureInfo.InvariantCulture, - " S{0}E{1}", - info.SeasonNumber.Value.ToString("00", CultureInfo.InvariantCulture), - info.EpisodeNumber.Value.ToString("00", CultureInfo.InvariantCulture)); - addHyphen = false; - } - else if (info.OriginalAirDate.HasValue) - { - if (info.OriginalAirDate.Value.Date.Equals(info.StartDate.Date)) - { - name += " " + GetDateString(info.StartDate); - } - else - { - name += " " + info.OriginalAirDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - } - } - else - { - name += " " + GetDateString(info.StartDate); - } - - if (!string.IsNullOrWhiteSpace(info.EpisodeTitle)) - { - var tmpName = name; - if (addHyphen) - { - tmpName += " -"; - } - - tmpName += " " + info.EpisodeTitle; - // Since the filename will be used with file ext. (.mp4, .ts, etc) - if (Encoding.UTF8.GetByteCount(tmpName) < 250) - { - name = tmpName; - } - } - } - else if (info.IsMovie && info.ProductionYear is not null) - { - name += " (" + info.ProductionYear + ")"; - } - else - { - name += " " + GetDateString(info.StartDate); - } - - return name; - } - - private static string GetDateString(DateTime date) - { - return date.ToLocalTime().ToString("yyyy_MM_dd_HH_mm_ss", CultureInfo.InvariantCulture); - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs deleted file mode 100644 index bf28f3b67d..0000000000 --- a/Emby.Server.Implementations/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 Emby.Server.Implementations.LiveTv.EmbyTV -{ - public class SeriesTimerManager : ItemDataProvider - { - public SeriesTimerManager(ILogger logger, string dataPath) - : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) - { - } - - /// - public override void Add(SeriesTimerInfo item) - { - ArgumentException.ThrowIfNullOrEmpty(item.Id); - - base.Add(item); - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs deleted file mode 100644 index 9f8441fa48..0000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs +++ /dev/null @@ -1,181 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Globalization; -using System.Linq; -using System.Threading; -using Jellyfin.Data.Events; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public class TimerManager : ItemDataProvider - { - private readonly ConcurrentDictionary _timers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - public TimerManager(ILogger logger, string dataPath) - : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) - { - } - - public event EventHandler>? TimerFired; - - public void RestartTimers() - { - StopTimers(); - - foreach (var item in GetAll()) - { - AddOrUpdateSystemTimer(item); - } - } - - public void StopTimers() - { - foreach (var pair in _timers.ToList()) - { - pair.Value.Dispose(); - } - - _timers.Clear(); - } - - public override void Delete(TimerInfo item) - { - base.Delete(item); - StopTimer(item); - } - - public override void Update(TimerInfo item) - { - base.Update(item); - AddOrUpdateSystemTimer(item); - } - - public void AddOrUpdate(TimerInfo item, bool resetTimer) - { - if (resetTimer) - { - AddOrUpdate(item); - return; - } - - base.AddOrUpdate(item); - } - - public override void AddOrUpdate(TimerInfo item) - { - base.AddOrUpdate(item); - AddOrUpdateSystemTimer(item); - } - - public override void Add(TimerInfo item) - { - ArgumentException.ThrowIfNullOrEmpty(item.Id); - - base.Add(item); - 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)) - { - return; - } - - var startDate = RecordingHelper.GetStartTime(item); - var now = DateTime.UtcNow; - - if (startDate < now) - { - TimerFired?.Invoke(this, new GenericEventArgs(item)); - return; - } - - var dueTime = startDate - now; - StartTimer(item, dueTime); - } - - private void StartTimer(TimerInfo item, TimeSpan dueTime) - { - var timer = new Timer(TimerCallback, item.Id, dueTime, TimeSpan.Zero); - - if (_timers.TryAdd(item.Id, timer)) - { - if (item.IsSeries) - { - Logger.LogInformation( - "Creating recording timer for {Id}, {Name} {SeasonNumber}x{EpisodeNumber:D2} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", - item.Id, - item.Name, - item.SeasonNumber, - item.EpisodeNumber, - item.ChannelId, - dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), - item.StartDate); - } - else - { - Logger.LogInformation( - "Creating recording timer for {Id}, {Name} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", - item.Id, - item.Name, - item.ChannelId, - dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), - item.StartDate); - } - } - else - { - timer.Dispose(); - Logger.LogWarning("Timer already exists for item {Id}", item.Id); - } - } - - private void StopTimer(TimerInfo item) - { - if (_timers.TryRemove(item.Id, out var timer)) - { - timer.Dispose(); - } - } - - private void TimerCallback(object? state) - { - var timerId = (string?)state ?? throw new ArgumentNullException(nameof(state)); - - var timer = GetAll().FirstOrDefault(i => string.Equals(i.Id, timerId, StringComparison.OrdinalIgnoreCase)); - if (timer is not null) - { - TimerFired?.Invoke(this, new GenericEventArgs(timer)); - } - } - - public TimerInfo? GetTimer(string id) - { - return 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)); - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs deleted file mode 100644 index 5be3a7488a..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ /dev/null @@ -1,808 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Json; -using System.Net.Mime; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos; -using Jellyfin.Extensions; -using Jellyfin.Extensions.Json; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.Listings -{ - public class SchedulesDirect : IListingsProvider, IDisposable - { - private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; - - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); - - private readonly ConcurrentDictionary _tokens = new ConcurrentDictionary(); - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private DateTime _lastErrorResponse; - private bool _disposed = false; - - public SchedulesDirect( - ILogger logger, - IHttpClientFactory httpClientFactory) - { - _logger = logger; - _httpClientFactory = httpClientFactory; - } - - /// - public string Name => "Schedules Direct"; - - /// - public string Type => nameof(SchedulesDirect); - - private static List GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc) - { - var dates = new List(); - - var start = new[] { startDateUtc, startDateUtc.ToLocalTime() }.Min().Date; - var end = new[] { endDateUtc, endDateUtc.ToLocalTime() }.Max().Date; - - while (start <= end) - { - dates.Add(start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); - start = start.AddDays(1); - } - - return dates; - } - - public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(channelId); - - // Normalize incoming input - channelId = channelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); - - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - if (string.IsNullOrEmpty(token)) - { - _logger.LogWarning("SchedulesDirect token is empty, returning empty program list"); - - return Enumerable.Empty(); - } - - var dates = GetScheduleRequestDates(startDateUtc, endDateUtc); - - _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId); - var requestList = new List() - { - new RequestScheduleForChannelDto() - { - StationId = channelId, - Date = dates - } - }; - - _logger.LogDebug("Request string for schedules is: {@RequestString}", requestList); - - using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules"); - options.Content = JsonContent.Create(requestList, options: _jsonOptions); - options.Headers.TryAddWithoutValidation("token", token); - using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - var dailySchedules = await response.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (dailySchedules is null) - { - return Array.Empty(); - } - - _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); - - using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs"); - programRequestOptions.Headers.TryAddWithoutValidation("token", token); - - var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); - programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions); - - using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); - var programDetails = await innerResponse.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (programDetails is null) - { - return Array.Empty(); - } - - var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y); - - var programIdsWithImages = programDetails - .Where(p => p.HasImageArtwork).Select(p => p.ProgramId) - .ToList(); - - var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); - - var programsInfo = new List(); - foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs)) - { - // _logger.LogDebug("Proccesing Schedule for statio ID " + stationID + - // " which corresponds to channel " + channelNumber + " and program id " + - // schedule.ProgramId + " which says it has images? " + - // programDict[schedule.ProgramId].hasImageArtwork); - - if (string.IsNullOrEmpty(schedule.ProgramId)) - { - continue; - } - - if (images is not null) - { - var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]); - if (imageIndex > -1) - { - var programEntry = programDict[schedule.ProgramId]; - - var allImages = images[imageIndex].Data; - var imagesWithText = allImages.Where(i => string.Equals(i.Text, "yes", StringComparison.OrdinalIgnoreCase)).ToList(); - var imagesWithoutText = allImages.Where(i => string.Equals(i.Text, "no", StringComparison.OrdinalIgnoreCase)).ToList(); - - const double DesiredAspect = 2.0 / 3; - - programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, DesiredAspect, token) ?? - GetProgramImage(ApiUrl, allImages, DesiredAspect, token); - - const double WideAspect = 16.0 / 9; - - programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, WideAspect, token); - - // Don't supply the same image twice - if (string.Equals(programEntry.PrimaryImage, programEntry.ThumbImage, StringComparison.Ordinal)) - { - programEntry.ThumbImage = null; - } - - programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, WideAspect, token); - - // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-LO", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-LOT", false); - } - } - - programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.ProgramId])); - } - - return programsInfo; - } - - private static int GetSizeOrder(ImageDataDto image) - { - if (int.TryParse(image.Height, out int value)) - { - return value; - } - - return 0; - } - - private static string GetChannelNumber(MapDto map) - { - var channelNumber = map.LogicalChannelNumber; - - if (string.IsNullOrWhiteSpace(channelNumber)) - { - channelNumber = map.Channel; - } - - if (string.IsNullOrWhiteSpace(channelNumber)) - { - channelNumber = map.AtscMajor + "." + map.AtscMinor; - } - - return channelNumber.TrimStart('0'); - } - - private static bool IsMovie(ProgramDetailsDto programInfo) - { - return string.Equals(programInfo.EntityType, "movie", StringComparison.OrdinalIgnoreCase); - } - - private ProgramInfo GetProgram(string channelId, ProgramDto programInfo, ProgramDetailsDto details) - { - if (programInfo.AirDateTime is null) - { - return null; - } - - var startAt = programInfo.AirDateTime.Value; - var endAt = startAt.AddSeconds(programInfo.Duration); - var audioType = ProgramAudio.Stereo; - - var programId = programInfo.ProgramId ?? string.Empty; - - string newID = programId + "T" + startAt.Ticks + "C" + channelId; - - if (programInfo.AudioProperties.Count != 0) - { - if (programInfo.AudioProperties.Contains("atmos", StringComparison.OrdinalIgnoreCase)) - { - audioType = ProgramAudio.Atmos; - } - else if (programInfo.AudioProperties.Contains("dd 5.1", StringComparison.OrdinalIgnoreCase)) - { - audioType = ProgramAudio.DolbyDigital; - } - else if (programInfo.AudioProperties.Contains("dd", StringComparison.OrdinalIgnoreCase)) - { - audioType = ProgramAudio.DolbyDigital; - } - else if (programInfo.AudioProperties.Contains("stereo", StringComparison.OrdinalIgnoreCase)) - { - audioType = ProgramAudio.Stereo; - } - else - { - audioType = ProgramAudio.Mono; - } - } - - string episodeTitle = null; - if (details.EpisodeTitle150 is not null) - { - episodeTitle = details.EpisodeTitle150; - } - - var info = new ProgramInfo - { - ChannelId = channelId, - Id = newID, - StartDate = startAt, - EndDate = endAt, - Name = details.Titles[0].Title120 ?? "Unknown", - OfficialRating = null, - CommunityRating = null, - EpisodeTitle = episodeTitle, - Audio = audioType, - // IsNew = programInfo.@new ?? false, - IsRepeat = programInfo.New is null, - IsSeries = string.Equals(details.EntityType, "episode", StringComparison.OrdinalIgnoreCase), - ImageUrl = details.PrimaryImage, - ThumbImageUrl = details.ThumbImage, - IsKids = string.Equals(details.Audience, "children", StringComparison.OrdinalIgnoreCase), - IsSports = string.Equals(details.EntityType, "sports", StringComparison.OrdinalIgnoreCase), - IsMovie = IsMovie(details), - Etag = programInfo.Md5, - IsLive = string.Equals(programInfo.LiveTapeDelay, "live", StringComparison.OrdinalIgnoreCase), - IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).Contains("premiere", StringComparison.OrdinalIgnoreCase) - }; - - var showId = programId; - - if (!info.IsSeries) - { - // It's also a series if it starts with SH - info.IsSeries = showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) && showId.Length >= 14; - } - - // According to SchedulesDirect, these are generic, unidentified episodes - // SH005316560000 - var hasUniqueShowId = !showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) || - !showId.EndsWith("0000", StringComparison.OrdinalIgnoreCase); - - if (!hasUniqueShowId) - { - showId = newID; - } - - info.ShowId = showId; - - if (programInfo.VideoProperties is not null) - { - info.IsHD = programInfo.VideoProperties.Contains("hdtv", StringComparison.OrdinalIgnoreCase); - info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparison.OrdinalIgnoreCase); - } - - if (details.ContentRating is not null && details.ContentRating.Count > 0) - { - info.OfficialRating = details.ContentRating[0].Code.Replace("TV", "TV-", StringComparison.Ordinal) - .Replace("--", "-", StringComparison.Ordinal); - - var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" }; - if (invalid.Contains(info.OfficialRating, StringComparison.OrdinalIgnoreCase)) - { - info.OfficialRating = null; - } - } - - if (details.Descriptions is not null) - { - if (details.Descriptions.Description1000 is not null && details.Descriptions.Description1000.Count > 0) - { - info.Overview = details.Descriptions.Description1000[0].Description; - } - else if (details.Descriptions.Description100 is not null && details.Descriptions.Description100.Count > 0) - { - info.Overview = details.Descriptions.Description100[0].Description; - } - } - - if (info.IsSeries) - { - info.SeriesId = programId.Substring(0, 10); - - info.SeriesProviderIds[MetadataProvider.Zap2It.ToString()] = info.SeriesId; - - if (details.Metadata is not null) - { - foreach (var metadataProgram in details.Metadata) - { - var gracenote = metadataProgram.Gracenote; - if (gracenote is not null) - { - info.SeasonNumber = gracenote.Season; - - if (gracenote.Episode > 0) - { - info.EpisodeNumber = gracenote.Episode; - } - - break; - } - } - } - } - - if (details.OriginalAirDate is not null) - { - info.OriginalAirDate = details.OriginalAirDate; - info.ProductionYear = info.OriginalAirDate.Value.Year; - } - - if (details.Movie is not null) - { - if (!string.IsNullOrEmpty(details.Movie.Year) - && int.TryParse(details.Movie.Year, out int year)) - { - info.ProductionYear = year; - } - } - - if (details.Genres is not null) - { - info.Genres = details.Genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); - info.IsNews = details.Genres.Contains("news", StringComparison.OrdinalIgnoreCase); - - if (info.Genres.Contains("children", StringComparison.OrdinalIgnoreCase)) - { - info.IsKids = true; - } - } - - return info; - } - - private static string GetProgramImage(string apiUrl, IEnumerable images, double desiredAspect, string token) - { - var match = images - .OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i))) - .ThenByDescending(i => GetSizeOrder(i)) - .FirstOrDefault(); - - if (match is null) - { - return null; - } - - var uri = match.Uri; - - if (string.IsNullOrWhiteSpace(uri)) - { - return null; - } - - if (uri.Contains("http", StringComparison.OrdinalIgnoreCase)) - { - return uri; - } - - return apiUrl + "/image/" + uri + "?token=" + token; - } - - private static double GetAspectRatio(ImageDataDto i) - { - int width = 0; - int height = 0; - - if (!string.IsNullOrWhiteSpace(i.Width)) - { - _ = int.TryParse(i.Width, out width); - } - - if (!string.IsNullOrWhiteSpace(i.Height)) - { - _ = int.TryParse(i.Height, out height); - } - - if (height == 0 || width == 0) - { - return 0; - } - - double result = width; - result /= height; - return result; - } - - private async Task> GetImageForPrograms( - ListingsProviderInfo info, - IReadOnlyList programIds, - CancellationToken cancellationToken) - { - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - if (programIds.Count == 0) - { - return Array.Empty(); - } - - StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); - foreach (var i in programIds) - { - str.Append('"') - .Append(i[..10]) - .Append("\","); - } - - // Remove last , - str.Length--; - str.Append(']'); - - using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs") - { - Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json) - }; - message.Headers.TryAddWithoutValidation("token", token); - - try - { - using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false); - return await innerResponse2.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting image info from schedules direct"); - - return Array.Empty(); - } - } - - public async Task> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken) - { - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - var lineups = new List(); - - if (string.IsNullOrWhiteSpace(token)) - { - return lineups; - } - - using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&postalcode=" + location); - options.Headers.TryAddWithoutValidation("token", token); - - try - { - using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); - var root = await httpResponse.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (root is not null) - { - foreach (HeadendsDto headend in root) - { - foreach (LineupDto lineup in headend.Lineups) - { - lineups.Add(new NameIdPair - { - Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name, - Id = lineup.Uri?[18..] - }); - } - } - } - else - { - _logger.LogInformation("No lineups available"); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting headends"); - } - - return lineups; - } - - private async Task GetToken(ListingsProviderInfo info, CancellationToken cancellationToken) - { - var username = info.Username; - - // Reset the token if there's no username - if (string.IsNullOrWhiteSpace(username)) - { - return null; - } - - var password = info.Password; - if (string.IsNullOrEmpty(password)) - { - return null; - } - - // Avoid hammering SD - if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1) - { - return null; - } - - if (!_tokens.TryGetValue(username, out NameValuePair savedToken)) - { - savedToken = new NameValuePair(); - _tokens.TryAdd(username, savedToken); - } - - if (!string.IsNullOrEmpty(savedToken.Name) - && long.TryParse(savedToken.Value, CultureInfo.InvariantCulture, out long ticks)) - { - // If it's under 24 hours old we can still use it - if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks) - { - return savedToken.Name; - } - } - - await _tokenSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); - savedToken.Name = result; - savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); - return result; - } - catch (HttpRequestException ex) - { - if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) - { - _tokens.Clear(); - _lastErrorResponse = DateTime.UtcNow; - } - - throw; - } - finally - { - _tokenSemaphore.Release(); - } - } - - private async Task Send( - HttpRequestMessage options, - bool enableRetry, - ListingsProviderInfo providerInfo, - CancellationToken cancellationToken, - HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) - { - var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); - if (response.IsSuccessStatusCode) - { - return response; - } - - // Response is automatically disposed in the calling function, - // so dispose manually if not returning. - response.Dispose(); - if (!enableRetry || (int)response.StatusCode >= 500) - { - throw new HttpRequestException( - string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), - null, - response.StatusCode); - } - - _tokens.Clear(); - options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); - return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false); - } - - private async Task GetTokenInternal( - string username, - string password, - CancellationToken cancellationToken) - { - using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token"); -#pragma warning disable CA5350 // SchedulesDirect is always SHA1. - var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password)); -#pragma warning restore CA5350 - // TODO: remove ToLower when Convert.ToHexString supports lowercase - // Schedules Direct requires the hex to be lowercase - string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); - options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); - - using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - var root = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (string.Equals(root?.Message, "OK", StringComparison.Ordinal)) - { - _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token); - return root.Token; - } - - throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message); - } - - private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken) - { - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - ArgumentException.ThrowIfNullOrEmpty(token); - ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); - - _logger.LogInformation("Adding new LineUp "); - - using var options = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId); - options.Headers.TryAddWithoutValidation("token", token); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - } - - private async Task HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); - - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - ArgumentException.ThrowIfNullOrEmpty(token); - - _logger.LogInformation("Headends on account "); - - using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups"); - options.Headers.TryAddWithoutValidation("token", token); - - try - { - using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - httpResponse.EnsureSuccessStatusCode(); - var root = await httpResponse.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); - return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false; - } - catch (HttpRequestException ex) - { - // SchedulesDirect returns 400 if no lineups are configured. - if (ex.StatusCode is HttpStatusCode.BadRequest) - { - return false; - } - - throw; - } - } - - public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) - { - if (validateLogin) - { - ArgumentException.ThrowIfNullOrEmpty(info.Username); - ArgumentException.ThrowIfNullOrEmpty(info.Password); - } - - if (validateListings) - { - ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); - - var hasLineup = await HasLineup(info, CancellationToken.None).ConfigureAwait(false); - - if (!hasLineup) - { - await AddLineupToAccount(info, CancellationToken.None).ConfigureAwait(false); - } - } - } - - public Task> GetLineups(ListingsProviderInfo info, string country, string location) - { - return GetHeadends(info, country, location, CancellationToken.None); - } - - public async Task> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) - { - var listingsId = info.ListingsId; - ArgumentException.ThrowIfNullOrEmpty(listingsId); - - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - ArgumentException.ThrowIfNullOrEmpty(token); - - using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId); - options.Headers.TryAddWithoutValidation("token", token); - - using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - var root = await httpResponse.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (root is null) - { - return new List(); - } - - _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count); - _logger.LogInformation("Mapping Stations to Channel"); - - var allStations = root.Stations; - - var map = root.Map; - var list = new List(map.Count); - foreach (var channel in map) - { - var channelNumber = GetChannelNumber(channel); - - var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, StringComparison.OrdinalIgnoreCase)); - var station = stationIndex == -1 - ? new StationDto { StationId = channel.StationId } - : allStations[stationIndex]; - - var channelInfo = new ChannelInfo - { - Id = station.StationId, - CallSign = station.Callsign, - Number = channelNumber, - Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name - }; - - if (station.Logo is not null) - { - channelInfo.ImageUrl = station.Logo.Url; - } - - list.Add(channelInfo); - } - - return list; - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and optionally managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _tokenSemaphore?.Dispose(); - } - - _disposed = true; - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs deleted file mode 100644 index 95ac996e01..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Broadcaster dto. - /// - public class BroadcasterDto - { - /// - /// Gets or sets the city. - /// - [JsonPropertyName("city")] - public string? City { get; set; } - - /// - /// Gets or sets the state. - /// - [JsonPropertyName("state")] - public string? State { get; set; } - - /// - /// Gets or sets the postal code. - /// - [JsonPropertyName("postalCode")] - public string? Postalcode { get; set; } - - /// - /// Gets or sets the country. - /// - [JsonPropertyName("country")] - public string? Country { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs deleted file mode 100644 index f6251b9ad8..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Caption dto. - /// - public class CaptionDto - { - /// - /// Gets or sets the content. - /// - [JsonPropertyName("content")] - public string? Content { get; set; } - - /// - /// Gets or sets the lang. - /// - [JsonPropertyName("lang")] - public string? Lang { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs deleted file mode 100644 index 0b7a2c63ad..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Cast dto. - /// - public class CastDto - { - /// - /// Gets or sets the billing order. - /// - [JsonPropertyName("billingOrder")] - public string? BillingOrder { get; set; } - - /// - /// Gets or sets the role. - /// - [JsonPropertyName("role")] - public string? Role { get; set; } - - /// - /// Gets or sets the name id. - /// - [JsonPropertyName("nameId")] - public string? NameId { get; set; } - - /// - /// Gets or sets the person id. - /// - [JsonPropertyName("personId")] - public string? PersonId { get; set; } - - /// - /// Gets or sets the name. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// Gets or sets the character name. - /// - [JsonPropertyName("characterName")] - public string? CharacterName { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs deleted file mode 100644 index 87c327ed82..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Channel dto. - /// - public class ChannelDto - { - /// - /// Gets or sets the list of maps. - /// - [JsonPropertyName("map")] - public IReadOnlyList Map { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of stations. - /// - [JsonPropertyName("stations")] - public IReadOnlyList Stations { get; set; } = Array.Empty(); - - /// - /// Gets or sets the metadata. - /// - [JsonPropertyName("metadata")] - public MetadataDto? Metadata { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs deleted file mode 100644 index c19cd2e48d..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Content rating dto. - /// - public class ContentRatingDto - { - /// - /// Gets or sets the body. - /// - [JsonPropertyName("body")] - public string? Body { get; set; } - - /// - /// Gets or sets the code. - /// - [JsonPropertyName("code")] - public string? Code { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs deleted file mode 100644 index f00c9accdd..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Crew dto. - /// - public class CrewDto - { - /// - /// Gets or sets the billing order. - /// - [JsonPropertyName("billingOrder")] - public string? BillingOrder { get; set; } - - /// - /// Gets or sets the role. - /// - [JsonPropertyName("role")] - public string? Role { get; set; } - - /// - /// Gets or sets the name id. - /// - [JsonPropertyName("nameId")] - public string? NameId { get; set; } - - /// - /// Gets or sets the person id. - /// - [JsonPropertyName("personId")] - public string? PersonId { get; set; } - - /// - /// Gets or sets the name. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs deleted file mode 100644 index 1a371965cf..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Day dto. - /// - public class DayDto - { - /// - /// Gets or sets the station id. - /// - [JsonPropertyName("stationID")] - public string? StationId { get; set; } - - /// - /// Gets or sets the list of programs. - /// - [JsonPropertyName("programs")] - public IReadOnlyList Programs { get; set; } = Array.Empty(); - - /// - /// Gets or sets the metadata schedule. - /// - [JsonPropertyName("metadata")] - public MetadataScheduleDto? Metadata { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs deleted file mode 100644 index ca6ae7fb13..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Description 1_000 dto. - /// - public class Description1000Dto - { - /// - /// Gets or sets the description language. - /// - [JsonPropertyName("descriptionLanguage")] - public string? DescriptionLanguage { get; set; } - - /// - /// Gets or sets the description. - /// - [JsonPropertyName("description")] - public string? Description { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs deleted file mode 100644 index 1577219ed2..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Description 100 dto. - /// - public class Description100Dto - { - /// - /// Gets or sets the description language. - /// - [JsonPropertyName("descriptionLanguage")] - public string? DescriptionLanguage { get; set; } - - /// - /// Gets or sets the description. - /// - [JsonPropertyName("description")] - public string? Description { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs deleted file mode 100644 index eaf4a340bd..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Descriptions program dto. - /// - public class DescriptionsProgramDto - { - /// - /// Gets or sets the list of description 100. - /// - [JsonPropertyName("description100")] - public IReadOnlyList Description100 { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of description1000. - /// - [JsonPropertyName("description1000")] - public IReadOnlyList Description1000 { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs deleted file mode 100644 index fbdfb1f716..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Event details dto. - /// - public class EventDetailsDto - { - /// - /// Gets or sets the sub type. - /// - [JsonPropertyName("subType")] - public string? SubType { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs deleted file mode 100644 index 6852d89d71..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Gracenote dto. - /// - public class GracenoteDto - { - /// - /// Gets or sets the season. - /// - [JsonPropertyName("season")] - public int Season { get; set; } - - /// - /// Gets or sets the episode. - /// - [JsonPropertyName("episode")] - public int Episode { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs deleted file mode 100644 index b9844562f3..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Headends dto. - /// - public class HeadendsDto - { - /// - /// Gets or sets the headend. - /// - [JsonPropertyName("headend")] - public string? Headend { get; set; } - - /// - /// Gets or sets the transport. - /// - [JsonPropertyName("transport")] - public string? Transport { get; set; } - - /// - /// Gets or sets the location. - /// - [JsonPropertyName("location")] - public string? Location { get; set; } - - /// - /// Gets or sets the list of lineups. - /// - [JsonPropertyName("lineups")] - public IReadOnlyList Lineups { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs deleted file mode 100644 index a1ae3ca6d4..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Image data dto. - /// - public class ImageDataDto - { - /// - /// Gets or sets the width. - /// - [JsonPropertyName("width")] - public string? Width { get; set; } - - /// - /// Gets or sets the height. - /// - [JsonPropertyName("height")] - public string? Height { get; set; } - - /// - /// Gets or sets the uri. - /// - [JsonPropertyName("uri")] - public string? Uri { get; set; } - - /// - /// Gets or sets the size. - /// - [JsonPropertyName("size")] - public string? Size { get; set; } - - /// - /// Gets or sets the aspect. - /// - [JsonPropertyName("aspect")] - public string? Aspect { get; set; } - - /// - /// Gets or sets the category. - /// - [JsonPropertyName("category")] - public string? Category { get; set; } - - /// - /// Gets or sets the text. - /// - [JsonPropertyName("text")] - public string? Text { get; set; } - - /// - /// Gets or sets the primary. - /// - [JsonPropertyName("primary")] - public string? Primary { get; set; } - - /// - /// Gets or sets the tier. - /// - [JsonPropertyName("tier")] - public string? Tier { get; set; } - - /// - /// Gets or sets the caption. - /// - [JsonPropertyName("caption")] - public CaptionDto? Caption { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs deleted file mode 100644 index 3dc64e5d8a..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// The lineup dto. - /// - public class LineupDto - { - /// - /// Gets or sets the linup. - /// - [JsonPropertyName("lineup")] - public string? Lineup { get; set; } - - /// - /// Gets or sets the lineup name. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// Gets or sets the transport. - /// - [JsonPropertyName("transport")] - public string? Transport { get; set; } - - /// - /// Gets or sets the location. - /// - [JsonPropertyName("location")] - public string? Location { get; set; } - - /// - /// Gets or sets the uri. - /// - [JsonPropertyName("uri")] - public string? Uri { get; set; } - - /// - /// Gets or sets a value indicating whether this lineup was deleted. - /// - [JsonPropertyName("isDeleted")] - public bool? IsDeleted { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs deleted file mode 100644 index f190817813..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Lineups dto. - /// - public class LineupsDto - { - /// - /// Gets or sets the response code. - /// - [JsonPropertyName("code")] - public int Code { get; set; } - - /// - /// Gets or sets the server id. - /// - [JsonPropertyName("serverID")] - public string? ServerId { get; set; } - - /// - /// Gets or sets the datetime. - /// - [JsonPropertyName("datetime")] - public DateTime? LineupTimestamp { get; set; } - - /// - /// Gets or sets the list of lineups. - /// - [JsonPropertyName("lineups")] - public IReadOnlyList Lineups { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs deleted file mode 100644 index fecc55e037..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Logo dto. - /// - public class LogoDto - { - /// - /// Gets or sets the url. - /// - [JsonPropertyName("URL")] - public string? Url { get; set; } - - /// - /// Gets or sets the height. - /// - [JsonPropertyName("height")] - public int Height { get; set; } - - /// - /// Gets or sets the width. - /// - [JsonPropertyName("width")] - public int Width { get; set; } - - /// - /// Gets or sets the md5. - /// - [JsonPropertyName("md5")] - public string? Md5 { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs deleted file mode 100644 index ffd02d474b..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Map dto. - /// - public class MapDto - { - /// - /// Gets or sets the station id. - /// - [JsonPropertyName("stationID")] - public string? StationId { get; set; } - - /// - /// Gets or sets the channel. - /// - [JsonPropertyName("channel")] - public string? Channel { get; set; } - - /// - /// Gets or sets the provider callsign. - /// - [JsonPropertyName("providerCallsign")] - public string? ProvderCallsign { get; set; } - - /// - /// Gets or sets the logical channel number. - /// - [JsonPropertyName("logicalChannelNumber")] - public string? LogicalChannelNumber { get; set; } - - /// - /// Gets or sets the uhfvhf. - /// - [JsonPropertyName("uhfVhf")] - public int UhfVhf { get; set; } - - /// - /// Gets or sets the atsc major. - /// - [JsonPropertyName("atscMajor")] - public int AtscMajor { get; set; } - - /// - /// Gets or sets the atsc minor. - /// - [JsonPropertyName("atscMinor")] - public int AtscMinor { get; set; } - - /// - /// Gets or sets the match type. - /// - [JsonPropertyName("matchType")] - public string? MatchType { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs deleted file mode 100644 index 40faa493c5..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Metadata dto. - /// - public class MetadataDto - { - /// - /// Gets or sets the linup. - /// - [JsonPropertyName("lineup")] - public string? Lineup { get; set; } - - /// - /// Gets or sets the modified timestamp. - /// - [JsonPropertyName("modified")] - public string? Modified { get; set; } - - /// - /// Gets or sets the transport. - /// - [JsonPropertyName("transport")] - public string? Transport { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs deleted file mode 100644 index 43f2901566..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Metadata programs dto. - /// - public class MetadataProgramsDto - { - /// - /// Gets or sets the gracenote object. - /// - [JsonPropertyName("Gracenote")] - public GracenoteDto? Gracenote { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs deleted file mode 100644 index 04560ab55d..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Metadata schedule dto. - /// - public class MetadataScheduleDto - { - /// - /// Gets or sets the modified timestamp. - /// - [JsonPropertyName("modified")] - public string? Modified { get; set; } - - /// - /// Gets or sets the md5. - /// - [JsonPropertyName("md5")] - public string? Md5 { get; set; } - - /// - /// Gets or sets the start date. - /// - [JsonPropertyName("startDate")] - public DateTime? StartDate { get; set; } - - /// - /// Gets or sets the end date. - /// - [JsonPropertyName("endDate")] - public DateTime? EndDate { get; set; } - - /// - /// Gets or sets the days count. - /// - [JsonPropertyName("days")] - public int Days { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs deleted file mode 100644 index 31bef423b2..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Movie dto. - /// - public class MovieDto - { - /// - /// Gets or sets the year. - /// - [JsonPropertyName("year")] - public string? Year { get; set; } - - /// - /// Gets or sets the duration. - /// - [JsonPropertyName("duration")] - public int Duration { get; set; } - - /// - /// Gets or sets the list of quality rating. - /// - [JsonPropertyName("qualityRating")] - public IReadOnlyList QualityRating { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs deleted file mode 100644 index e8b15dc07c..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Multipart dto. - /// - public class MultipartDto - { - /// - /// Gets or sets the part number. - /// - [JsonPropertyName("partNumber")] - public int PartNumber { get; set; } - - /// - /// Gets or sets the total parts. - /// - [JsonPropertyName("totalParts")] - public int TotalParts { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs deleted file mode 100644 index 84c48f67f3..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Program details dto. - /// - public class ProgramDetailsDto - { - /// - /// Gets or sets the audience. - /// - [JsonPropertyName("audience")] - public string? Audience { get; set; } - - /// - /// Gets or sets the program id. - /// - [JsonPropertyName("programID")] - public string? ProgramId { get; set; } - - /// - /// Gets or sets the list of titles. - /// - [JsonPropertyName("titles")] - public IReadOnlyList Titles { get; set; } = Array.Empty(); - - /// - /// Gets or sets the event details object. - /// - [JsonPropertyName("eventDetails")] - public EventDetailsDto? EventDetails { get; set; } - - /// - /// Gets or sets the descriptions. - /// - [JsonPropertyName("descriptions")] - public DescriptionsProgramDto? Descriptions { get; set; } - - /// - /// Gets or sets the original air date. - /// - [JsonPropertyName("originalAirDate")] - public DateTime? OriginalAirDate { get; set; } - - /// - /// Gets or sets the list of genres. - /// - [JsonPropertyName("genres")] - public IReadOnlyList Genres { get; set; } = Array.Empty(); - - /// - /// Gets or sets the episode title. - /// - [JsonPropertyName("episodeTitle150")] - public string? EpisodeTitle150 { get; set; } - - /// - /// Gets or sets the list of metadata. - /// - [JsonPropertyName("metadata")] - public IReadOnlyList Metadata { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of content raitings. - /// - [JsonPropertyName("contentRating")] - public IReadOnlyList ContentRating { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of cast. - /// - [JsonPropertyName("cast")] - public IReadOnlyList Cast { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of crew. - /// - [JsonPropertyName("crew")] - public IReadOnlyList Crew { get; set; } = Array.Empty(); - - /// - /// Gets or sets the entity type. - /// - [JsonPropertyName("entityType")] - public string? EntityType { get; set; } - - /// - /// Gets or sets the show type. - /// - [JsonPropertyName("showType")] - public string? ShowType { get; set; } - - /// - /// Gets or sets a value indicating whether there is image artwork. - /// - [JsonPropertyName("hasImageArtwork")] - public bool HasImageArtwork { get; set; } - - /// - /// Gets or sets the primary image. - /// - [JsonPropertyName("primaryImage")] - public string? PrimaryImage { get; set; } - - /// - /// Gets or sets the thumb image. - /// - [JsonPropertyName("thumbImage")] - public string? ThumbImage { get; set; } - - /// - /// Gets or sets the backdrop image. - /// - [JsonPropertyName("backdropImage")] - public string? BackdropImage { get; set; } - - /// - /// Gets or sets the banner image. - /// - [JsonPropertyName("bannerImage")] - public string? BannerImage { get; set; } - - /// - /// Gets or sets the image id. - /// - [JsonPropertyName("imageID")] - public string? ImageId { get; set; } - - /// - /// Gets or sets the md5. - /// - [JsonPropertyName("md5")] - public string? Md5 { get; set; } - - /// - /// Gets or sets the list of content advisory. - /// - [JsonPropertyName("contentAdvisory")] - public IReadOnlyList ContentAdvisory { get; set; } = Array.Empty(); - - /// - /// Gets or sets the movie object. - /// - [JsonPropertyName("movie")] - public MovieDto? Movie { get; set; } - - /// - /// Gets or sets the list of recommendations. - /// - [JsonPropertyName("recommendations")] - public IReadOnlyList Recommendations { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs deleted file mode 100644 index 60389b45bf..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Program dto. - /// - public class ProgramDto - { - /// - /// Gets or sets the program id. - /// - [JsonPropertyName("programID")] - public string? ProgramId { get; set; } - - /// - /// Gets or sets the air date time. - /// - [JsonPropertyName("airDateTime")] - public DateTime? AirDateTime { get; set; } - - /// - /// Gets or sets the duration. - /// - [JsonPropertyName("duration")] - public int Duration { get; set; } - - /// - /// Gets or sets the md5. - /// - [JsonPropertyName("md5")] - public string? Md5 { get; set; } - - /// - /// Gets or sets the list of audio properties. - /// - [JsonPropertyName("audioProperties")] - public IReadOnlyList AudioProperties { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of video properties. - /// - [JsonPropertyName("videoProperties")] - public IReadOnlyList VideoProperties { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of ratings. - /// - [JsonPropertyName("ratings")] - public IReadOnlyList Ratings { get; set; } = Array.Empty(); - - /// - /// Gets or sets a value indicating whether this program is new. - /// - [JsonPropertyName("new")] - public bool? New { get; set; } - - /// - /// Gets or sets the multipart object. - /// - [JsonPropertyName("multipart")] - public MultipartDto? Multipart { get; set; } - - /// - /// Gets or sets the live tape delay. - /// - [JsonPropertyName("liveTapeDelay")] - public string? LiveTapeDelay { get; set; } - - /// - /// Gets or sets a value indicating whether this is the premiere. - /// - [JsonPropertyName("premiere")] - public bool Premiere { get; set; } - - /// - /// Gets or sets a value indicating whether this is a repeat. - /// - [JsonPropertyName("repeat")] - public bool Repeat { get; set; } - - /// - /// Gets or sets the premiere or finale. - /// - [JsonPropertyName("isPremiereOrFinale")] - public string? IsPremiereOrFinale { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs deleted file mode 100644 index c5ddcf7c51..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Quality rating dto. - /// - public class QualityRatingDto - { - /// - /// Gets or sets the ratings body. - /// - [JsonPropertyName("ratingsBody")] - public string? RatingsBody { get; set; } - - /// - /// Gets or sets the rating. - /// - [JsonPropertyName("rating")] - public string? Rating { get; set; } - - /// - /// Gets or sets the min rating. - /// - [JsonPropertyName("minRating")] - public string? MinRating { get; set; } - - /// - /// Gets or sets the max rating. - /// - [JsonPropertyName("maxRating")] - public string? MaxRating { get; set; } - - /// - /// Gets or sets the increment. - /// - [JsonPropertyName("increment")] - public string? Increment { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs deleted file mode 100644 index e04b619a4d..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Rating dto. - /// - public class RatingDto - { - /// - /// Gets or sets the body. - /// - [JsonPropertyName("body")] - public string? Body { get; set; } - - /// - /// Gets or sets the code. - /// - [JsonPropertyName("code")] - public string? Code { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs deleted file mode 100644 index c8f79fd1c2..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Recommendation dto. - /// - public class RecommendationDto - { - /// - /// Gets or sets the program id. - /// - [JsonPropertyName("programID")] - public string? ProgramId { get; set; } - - /// - /// Gets or sets the title. - /// - [JsonPropertyName("title120")] - public string? Title120 { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs deleted file mode 100644 index 0cd05709b3..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Request schedule for channel dto. - /// - public class RequestScheduleForChannelDto - { - /// - /// Gets or sets the station id. - /// - [JsonPropertyName("stationID")] - public string? StationId { get; set; } - - /// - /// Gets or sets the list of dates. - /// - [JsonPropertyName("date")] - public IReadOnlyList Date { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs deleted file mode 100644 index 84e224b71e..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Show image dto. - /// - public class ShowImagesDto - { - /// - /// Gets or sets the program id. - /// - [JsonPropertyName("programID")] - public string? ProgramId { get; set; } - - /// - /// Gets or sets the list of data. - /// - [JsonPropertyName("data")] - public IReadOnlyList Data { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs deleted file mode 100644 index d797fd49b1..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Station dto. - /// - public class StationDto - { - /// - /// Gets or sets the station id. - /// - [JsonPropertyName("stationID")] - public string? StationId { get; set; } - - /// - /// Gets or sets the name. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// Gets or sets the callsign. - /// - [JsonPropertyName("callsign")] - public string? Callsign { get; set; } - - /// - /// Gets or sets the broadcast language. - /// - [JsonPropertyName("broadcastLanguage")] - public IReadOnlyList BroadcastLanguage { get; set; } = Array.Empty(); - - /// - /// Gets or sets the description language. - /// - [JsonPropertyName("descriptionLanguage")] - public IReadOnlyList DescriptionLanguage { get; set; } = Array.Empty(); - - /// - /// Gets or sets the broadcaster. - /// - [JsonPropertyName("broadcaster")] - public BroadcasterDto? Broadcaster { get; set; } - - /// - /// Gets or sets the affiliate. - /// - [JsonPropertyName("affiliate")] - public string? Affiliate { get; set; } - - /// - /// Gets or sets the logo. - /// - [JsonPropertyName("logo")] - public LogoDto? Logo { get; set; } - - /// - /// Gets or sets a value indicating whether it is commercial free. - /// - [JsonPropertyName("isCommercialFree")] - public bool? IsCommercialFree { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs deleted file mode 100644 index 61cd4a9b00..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Title dto. - /// - public class TitleDto - { - /// - /// Gets or sets the title. - /// - [JsonPropertyName("title120")] - public string? Title120 { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs deleted file mode 100644 index afb9994869..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// The token dto. - /// - public class TokenDto - { - /// - /// Gets or sets the response code. - /// - [JsonPropertyName("code")] - public int Code { get; set; } - - /// - /// Gets or sets the response message. - /// - [JsonPropertyName("message")] - public string? Message { get; set; } - - /// - /// Gets or sets the server id. - /// - [JsonPropertyName("serverID")] - public string? ServerId { get; set; } - - /// - /// Gets or sets the token. - /// - [JsonPropertyName("token")] - public string? Token { get; set; } - - /// - /// Gets or sets the current datetime. - /// - [JsonPropertyName("datetime")] - public DateTime? TokenTimestamp { get; set; } - - /// - /// Gets or sets the response message. - /// - [JsonPropertyName("response")] - public string? Response { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs deleted file mode 100644 index e60e9dcc1c..0000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs +++ /dev/null @@ -1,267 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Extensions; -using Jellyfin.XmlTv; -using Jellyfin.XmlTv.Entities; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.Listings -{ - public class XmlTvListingsProvider : IListingsProvider - { - private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1); - - private readonly IServerConfigurationManager _config; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger _logger; - - public XmlTvListingsProvider( - IServerConfigurationManager config, - IHttpClientFactory httpClientFactory, - ILogger logger) - { - _config = config; - _httpClientFactory = httpClientFactory; - _logger = logger; - } - - public string Name => "XmlTV"; - - public string Type => "xmltv"; - - private string GetLanguage(ListingsProviderInfo info) - { - if (!string.IsNullOrWhiteSpace(info.PreferredLanguage)) - { - return info.PreferredLanguage; - } - - return _config.Configuration.PreferredMetadataLanguage; - } - - private async Task GetXml(ListingsProviderInfo info, CancellationToken cancellationToken) - { - _logger.LogInformation("xmltv path: {Path}", info.Path); - - string cacheFilename = info.Id + ".xml"; - string cacheFile = Path.Combine(_config.ApplicationPaths.CachePath, "xmltv", cacheFilename); - - if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge)) - { - return cacheFile; - } - - // Must check if file exists as parent directory may not exist. - if (File.Exists(cacheFile)) - { - File.Delete(cacheFile); - } - else - { - Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); - } - - if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); - - using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); - var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) - { - return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); - } - } - else - { - var stream = AsyncFile.OpenRead(info.Path); - await using (stream.ConfigureAwait(false)) - { - return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); - } - } - } - - private async Task UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken) - { - var fileStream = new FileStream( - file, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - IODefaults.FileStreamBufferSize, - FileOptions.Asynchronous); - - await using (fileStream.ConfigureAwait(false)) - { - if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase)) - { - try - { - using var reader = new GZipStream(stream, CompressionMode.Decompress); - await reader.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error extracting from gz file {File}", originalUrl); - } - } - else - { - await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); - } - - return file; - } - } - - public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(channelId)) - { - throw new ArgumentNullException(nameof(channelId)); - } - - _logger.LogDebug("Getting xmltv programs for channel {Id}", channelId); - - string path = await GetXml(info, cancellationToken).ConfigureAwait(false); - _logger.LogDebug("Opening XmlTvReader for {Path}", path); - var reader = new XmlTvReader(path, GetLanguage(info)); - - return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken) - .Select(p => GetProgramInfo(p, info)); - } - - private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info) - { - string episodeTitle = program.Episode.Title; - var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList(); - - var programInfo = new ProgramInfo - { - ChannelId = program.ChannelId, - EndDate = program.EndDate.UtcDateTime, - EpisodeNumber = program.Episode.Episode, - EpisodeTitle = episodeTitle, - Genres = programCategories, - StartDate = program.StartDate.UtcDateTime, - Name = program.Title, - Overview = program.Description, - ProductionYear = program.CopyrightDate?.Year, - SeasonNumber = program.Episode.Series, - IsSeries = program.Episode.Series is not null, - IsRepeat = program.IsPreviouslyShown && !program.IsNew, - IsPremiere = program.Premiere is not null, - IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - ImageUrl = string.IsNullOrEmpty(program.Icon?.Source) ? null : program.Icon.Source, - HasImage = !string.IsNullOrEmpty(program.Icon?.Source), - OfficialRating = string.IsNullOrEmpty(program.Rating?.Value) ? null : program.Rating.Value, - CommunityRating = program.StarRating, - SeriesId = program.Episode.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture) - }; - - if (string.IsNullOrWhiteSpace(program.ProgramId)) - { - string uniqueString = (program.Title ?? string.Empty) + (episodeTitle ?? string.Empty); - - if (programInfo.SeasonNumber.HasValue) - { - uniqueString = "-" + programInfo.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture); - } - - if (programInfo.EpisodeNumber.HasValue) - { - uniqueString = "-" + programInfo.EpisodeNumber.Value.ToString(CultureInfo.InvariantCulture); - } - - programInfo.ShowId = uniqueString.GetMD5().ToString("N", CultureInfo.InvariantCulture); - - // If we don't have valid episode info, assume it's a unique program, otherwise recordings might be skipped - if (programInfo.IsSeries - && !programInfo.IsRepeat - && (programInfo.EpisodeNumber ?? 0) == 0) - { - programInfo.ShowId += programInfo.StartDate.Ticks.ToString(CultureInfo.InvariantCulture); - } - } - else - { - programInfo.ShowId = program.ProgramId; - } - - // Construct an id from the channel and start date - programInfo.Id = string.Format(CultureInfo.InvariantCulture, "{0}_{1:O}", program.ChannelId, program.StartDate); - - if (programInfo.IsMovie) - { - programInfo.IsSeries = false; - programInfo.EpisodeNumber = null; - programInfo.EpisodeTitle = null; - } - - return programInfo; - } - - public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) - { - // Assume all urls are valid. check files for existence - if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path)) - { - throw new FileNotFoundException("Could not find the XmlTv file specified:", info.Path); - } - - return Task.CompletedTask; - } - - public async Task> GetLineups(ListingsProviderInfo info, string country, string location) - { - // In theory this should never be called because there is always only one lineup - string path = await GetXml(info, CancellationToken.None).ConfigureAwait(false); - _logger.LogDebug("Opening XmlTvReader for {Path}", path); - var reader = new XmlTvReader(path, GetLanguage(info)); - IEnumerable results = reader.GetChannels(); - - // Should this method be async? - return results.Select(c => new NameIdPair() { Id = c.Id, Name = c.DisplayName }).ToList(); - } - - public async Task> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) - { - // In theory this should never be called because there is always only one lineup - string path = await GetXml(info, cancellationToken).ConfigureAwait(false); - _logger.LogDebug("Opening XmlTvReader for {Path}", path); - var reader = new XmlTvReader(path, GetLanguage(info)); - var results = reader.GetChannels(); - - // Should this method be async? - return results.Select(c => new ChannelInfo - { - Id = c.Id, - Name = c.DisplayName, - ImageUrl = string.IsNullOrEmpty(c.Icon?.Source) ? null : c.Icon.Source, - Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number - }).ToList(); - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs deleted file mode 100644 index 098f193fba..0000000000 --- a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.LiveTv; - -namespace Emby.Server.Implementations.LiveTv -{ - /// - /// implementation for . - /// - public class LiveTvConfigurationFactory : IConfigurationFactory - { - /// - public IEnumerable GetConfigurations() - { - return new ConfigurationStore[] - { - new ConfigurationStore - { - ConfigurationType = typeof(LiveTvOptions), - Key = "livetv" - } - }; - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs deleted file mode 100644 index 9326fbd5c7..0000000000 --- a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs +++ /dev/null @@ -1,548 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using MediaBrowser.Common; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv -{ - public class LiveTvDtoService - { - private const string InternalVersionNumber = "4"; - - private const string ServiceName = "Emby"; - - private readonly ILogger _logger; - private readonly IImageProcessor _imageProcessor; - private readonly IDtoService _dtoService; - private readonly IApplicationHost _appHost; - private readonly ILibraryManager _libraryManager; - - public LiveTvDtoService( - IDtoService dtoService, - IImageProcessor imageProcessor, - ILogger logger, - IApplicationHost appHost, - ILibraryManager libraryManager) - { - _dtoService = dtoService; - _imageProcessor = imageProcessor; - _logger = logger; - _appHost = appHost; - _libraryManager = libraryManager; - } - - public TimerInfoDto GetTimerInfoDto(TimerInfo info, ILiveTvService service, LiveTvProgram program, BaseItem channel) - { - var dto = new TimerInfoDto - { - Id = GetInternalTimerId(info.Id), - Overview = info.Overview, - EndDate = info.EndDate, - Name = info.Name, - StartDate = info.StartDate, - ExternalId = info.Id, - ChannelId = GetInternalChannelId(service.Name, info.ChannelId), - Status = info.Status, - SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null : GetInternalSeriesTimerId(info.SeriesTimerId).ToString("N", CultureInfo.InvariantCulture), - PrePaddingSeconds = info.PrePaddingSeconds, - PostPaddingSeconds = info.PostPaddingSeconds, - IsPostPaddingRequired = info.IsPostPaddingRequired, - IsPrePaddingRequired = info.IsPrePaddingRequired, - KeepUntil = info.KeepUntil, - ExternalChannelId = info.ChannelId, - ExternalSeriesTimerId = info.SeriesTimerId, - ServiceName = service.Name, - ExternalProgramId = info.ProgramId, - Priority = info.Priority, - RunTimeTicks = (info.EndDate - info.StartDate).Ticks, - ServerId = _appHost.SystemId - }; - - if (!string.IsNullOrEmpty(info.ProgramId)) - { - dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N", CultureInfo.InvariantCulture); - } - - if (program is not null) - { - dto.ProgramInfo = _dtoService.GetBaseItemDto(program, new DtoOptions()); - - if (info.Status != RecordingStatus.Cancelled && info.Status != RecordingStatus.Error) - { - dto.ProgramInfo.TimerId = dto.Id; - dto.ProgramInfo.Status = info.Status.ToString(); - } - - dto.ProgramInfo.SeriesTimerId = dto.SeriesTimerId; - - if (!string.IsNullOrEmpty(info.SeriesTimerId)) - { - FillImages(dto.ProgramInfo, info.Name, info.SeriesId); - } - } - - if (channel is not null) - { - dto.ChannelName = channel.Name; - - if (channel.HasImage(ImageType.Primary)) - { - dto.ChannelPrimaryImageTag = GetImageTag(channel); - } - } - - return dto; - } - - public SeriesTimerInfoDto GetSeriesTimerInfoDto(SeriesTimerInfo info, ILiveTvService service, string channelName) - { - var dto = new SeriesTimerInfoDto - { - Id = GetInternalSeriesTimerId(info.Id).ToString("N", CultureInfo.InvariantCulture), - Overview = info.Overview, - EndDate = info.EndDate, - Name = info.Name, - StartDate = info.StartDate, - ExternalId = info.Id, - PrePaddingSeconds = info.PrePaddingSeconds, - PostPaddingSeconds = info.PostPaddingSeconds, - IsPostPaddingRequired = info.IsPostPaddingRequired, - IsPrePaddingRequired = info.IsPrePaddingRequired, - Days = info.Days.ToArray(), - Priority = info.Priority, - RecordAnyChannel = info.RecordAnyChannel, - RecordAnyTime = info.RecordAnyTime, - SkipEpisodesInLibrary = info.SkipEpisodesInLibrary, - KeepUpTo = info.KeepUpTo, - KeepUntil = info.KeepUntil, - RecordNewOnly = info.RecordNewOnly, - ExternalChannelId = info.ChannelId, - ExternalProgramId = info.ProgramId, - ServiceName = service.Name, - ChannelName = channelName, - ServerId = _appHost.SystemId - }; - - if (!string.IsNullOrEmpty(info.ChannelId)) - { - dto.ChannelId = GetInternalChannelId(service.Name, info.ChannelId); - } - - if (!string.IsNullOrEmpty(info.ProgramId)) - { - dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N", CultureInfo.InvariantCulture); - } - - dto.DayPattern = info.Days is null ? null : GetDayPattern(info.Days.ToArray()); - - FillImages(dto, info.Name, info.SeriesId); - - return dto; - } - - private void FillImages(BaseItemDto dto, string seriesName, string programSeriesId) - { - var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Series }, - Name = seriesName, - Limit = 1, - ImageTypes = new ImageType[] { ImageType.Thumb }, - DtoOptions = new DtoOptions(false) - }).FirstOrDefault(); - - if (librarySeries is not null) - { - var image = librarySeries.GetImageInfo(ImageType.Thumb, 0); - if (image is not null) - { - try - { - dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image); - dto.ParentThumbItemId = librarySeries.Id; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - - image = librarySeries.GetImageInfo(ImageType.Backdrop, 0); - if (image is not null) - { - try - { - dto.ParentBackdropImageTags = new string[] - { - _imageProcessor.GetImageCacheTag(librarySeries, image) - }; - dto.ParentBackdropItemId = librarySeries.Id; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - } - - var program = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ExternalSeriesId = programSeriesId, - Limit = 1, - ImageTypes = new ImageType[] { ImageType.Primary }, - DtoOptions = new DtoOptions(false), - Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null - }).FirstOrDefault(); - - if (program is not null) - { - var image = program.GetImageInfo(ImageType.Primary, 0); - if (image is not null) - { - try - { - dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); - dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - - if (dto.ParentBackdropImageTags is null || dto.ParentBackdropImageTags.Length == 0) - { - image = program.GetImageInfo(ImageType.Backdrop, 0); - if (image is not null) - { - try - { - dto.ParentBackdropImageTags = new string[] - { - _imageProcessor.GetImageCacheTag(program, image) - }; - - dto.ParentBackdropItemId = program.Id; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - } - } - } - - private void FillImages(SeriesTimerInfoDto dto, string seriesName, string programSeriesId) - { - var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Series }, - Name = seriesName, - Limit = 1, - ImageTypes = new ImageType[] { ImageType.Thumb }, - DtoOptions = new DtoOptions(false) - }).FirstOrDefault(); - - if (librarySeries is not null) - { - var image = librarySeries.GetImageInfo(ImageType.Thumb, 0); - if (image is not null) - { - try - { - dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image); - dto.ParentThumbItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - - image = librarySeries.GetImageInfo(ImageType.Backdrop, 0); - if (image is not null) - { - try - { - dto.ParentBackdropImageTags = new string[] - { - _imageProcessor.GetImageCacheTag(librarySeries, image) - }; - dto.ParentBackdropItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - } - - var program = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Series }, - Name = seriesName, - Limit = 1, - ImageTypes = new ImageType[] { ImageType.Primary }, - DtoOptions = new DtoOptions(false) - }).FirstOrDefault(); - - if (program is null) - { - program = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ExternalSeriesId = programSeriesId, - Limit = 1, - ImageTypes = new ImageType[] { ImageType.Primary }, - DtoOptions = new DtoOptions(false), - Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null - }).FirstOrDefault(); - } - - if (program is not null) - { - var image = program.GetImageInfo(ImageType.Primary, 0); - if (image is not null) - { - try - { - dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); - dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "GetImageCacheTag raised an exception in LiveTvDtoService.FillImages."); - } - } - - if (dto.ParentBackdropImageTags is null || dto.ParentBackdropImageTags.Length == 0) - { - image = program.GetImageInfo(ImageType.Backdrop, 0); - if (image is not null) - { - try - { - dto.ParentBackdropImageTags = new[] - { - _imageProcessor.GetImageCacheTag(program, image) - }; - dto.ParentBackdropItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - } - } - } - - public DayPattern? GetDayPattern(DayOfWeek[] days) - { - DayPattern? pattern = null; - - if (days.Length > 0) - { - if (days.Length == 7) - { - pattern = DayPattern.Daily; - } - else if (days.Length == 2) - { - if (days.Contains(DayOfWeek.Saturday) && days.Contains(DayOfWeek.Sunday)) - { - pattern = DayPattern.Weekends; - } - } - else if (days.Length == 5) - { - if (days.Contains(DayOfWeek.Monday) && days.Contains(DayOfWeek.Tuesday) && days.Contains(DayOfWeek.Wednesday) && days.Contains(DayOfWeek.Thursday) && days.Contains(DayOfWeek.Friday)) - { - pattern = DayPattern.Weekdays; - } - } - } - - return pattern; - } - - internal string GetImageTag(BaseItem info) - { - try - { - return _imageProcessor.GetImageCacheTag(info, ImageType.Primary); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting image info for {Name}", info.Name); - } - - return null; - } - - public Guid GetInternalChannelId(string serviceName, string externalId) - { - var name = serviceName + externalId + InternalVersionNumber; - - return _libraryManager.GetNewItemId(name.ToLowerInvariant(), typeof(LiveTvChannel)); - } - - public string GetInternalTimerId(string externalId) - { - var name = ServiceName + externalId + InternalVersionNumber; - - return name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture); - } - - public Guid GetInternalSeriesTimerId(string externalId) - { - var name = ServiceName + externalId + InternalVersionNumber; - - return name.ToLowerInvariant().GetMD5(); - } - - public Guid GetInternalProgramId(string externalId) - { - var name = ServiceName + externalId + InternalVersionNumber; - - return _libraryManager.GetNewItemId(name.ToLowerInvariant(), typeof(LiveTvProgram)); - } - - public async Task GetTimerInfo(TimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken) - { - var info = new TimerInfo - { - Overview = dto.Overview, - EndDate = dto.EndDate, - Name = dto.Name, - StartDate = dto.StartDate, - Status = dto.Status, - PrePaddingSeconds = dto.PrePaddingSeconds, - PostPaddingSeconds = dto.PostPaddingSeconds, - IsPostPaddingRequired = dto.IsPostPaddingRequired, - IsPrePaddingRequired = dto.IsPrePaddingRequired, - KeepUntil = dto.KeepUntil, - Priority = dto.Priority, - SeriesTimerId = dto.ExternalSeriesTimerId, - ProgramId = dto.ExternalProgramId, - ChannelId = dto.ExternalChannelId, - Id = dto.ExternalId - }; - - // Convert internal server id's to external tv provider id's - if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id)) - { - var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false); - - info.Id = timer.ExternalId; - } - - if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) - { - var channel = _libraryManager.GetItemById(dto.ChannelId); - - if (channel is not null) - { - info.ChannelId = channel.ExternalId; - } - } - - if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId)) - { - var program = _libraryManager.GetItemById(dto.ProgramId); - - if (program is not null) - { - info.ProgramId = program.ExternalId; - } - } - - if (!string.IsNullOrEmpty(dto.SeriesTimerId) && string.IsNullOrEmpty(info.SeriesTimerId)) - { - var timer = await liveTv.GetSeriesTimer(dto.SeriesTimerId, cancellationToken).ConfigureAwait(false); - - if (timer is not null) - { - info.SeriesTimerId = timer.ExternalId; - } - } - - return info; - } - - public async Task GetSeriesTimerInfo(SeriesTimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken) - { - var info = new SeriesTimerInfo - { - Overview = dto.Overview, - EndDate = dto.EndDate, - Name = dto.Name, - StartDate = dto.StartDate, - PrePaddingSeconds = dto.PrePaddingSeconds, - PostPaddingSeconds = dto.PostPaddingSeconds, - IsPostPaddingRequired = dto.IsPostPaddingRequired, - IsPrePaddingRequired = dto.IsPrePaddingRequired, - Days = dto.Days.ToList(), - Priority = dto.Priority, - RecordAnyChannel = dto.RecordAnyChannel, - RecordAnyTime = dto.RecordAnyTime, - SkipEpisodesInLibrary = dto.SkipEpisodesInLibrary, - KeepUpTo = dto.KeepUpTo, - KeepUntil = dto.KeepUntil, - RecordNewOnly = dto.RecordNewOnly, - ProgramId = dto.ExternalProgramId, - ChannelId = dto.ExternalChannelId, - Id = dto.ExternalId - }; - - // Convert internal server id's to external tv provider id's - if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id)) - { - var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false); - - info.Id = timer.ExternalId; - } - - if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) - { - var channel = _libraryManager.GetItemById(dto.ChannelId); - - if (channel is not null) - { - info.ChannelId = channel.ExternalId; - } - } - - if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId)) - { - var program = _libraryManager.GetItemById(dto.ProgramId); - - if (program is not null) - { - info.ProgramId = program.ExternalId; - } - } - - return info; - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs deleted file mode 100644 index 426165de61..0000000000 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ /dev/null @@ -1,2408 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Emby.Server.Implementations.Library; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Events; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; -using MediaBrowser.Controller.Channels; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Controller.Sorting; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv -{ - /// - /// Class LiveTvManager. - /// - public class LiveTvManager : ILiveTvManager - { - private const int MaxGuideDays = 14; - private const string ExternalServiceTag = "ExternalServiceId"; - - private const string EtagKey = "ProgramEtag"; - - private readonly IServerConfigurationManager _config; - private readonly ILogger _logger; - private readonly IItemRepository _itemRepo; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly IUserDataManager _userDataManager; - private readonly ILibraryManager _libraryManager; - private readonly ITaskManager _taskManager; - private readonly ILocalizationManager _localization; - private readonly IFileSystem _fileSystem; - private readonly IChannelManager _channelManager; - private readonly LiveTvDtoService _tvDtoService; - - private ILiveTvService[] _services = Array.Empty(); - private ITunerHost[] _tunerHosts = Array.Empty(); - private IListingsProvider[] _listingProviders = Array.Empty(); - - public LiveTvManager( - IServerConfigurationManager config, - ILogger logger, - IItemRepository itemRepo, - IUserDataManager userDataManager, - IDtoService dtoService, - IUserManager userManager, - ILibraryManager libraryManager, - ITaskManager taskManager, - ILocalizationManager localization, - IFileSystem fileSystem, - IChannelManager channelManager, - LiveTvDtoService liveTvDtoService) - { - _config = config; - _logger = logger; - _itemRepo = itemRepo; - _userManager = userManager; - _libraryManager = libraryManager; - _taskManager = taskManager; - _localization = localization; - _fileSystem = fileSystem; - _dtoService = dtoService; - _userDataManager = userDataManager; - _channelManager = channelManager; - _tvDtoService = liveTvDtoService; - } - - public event EventHandler> SeriesTimerCancelled; - - public event EventHandler> TimerCancelled; - - public event EventHandler> TimerCreated; - - public event EventHandler> SeriesTimerCreated; - - /// - /// Gets the services. - /// - /// The services. - public IReadOnlyList Services => _services; - - public IReadOnlyList TunerHosts => _tunerHosts; - - public IReadOnlyList ListingProviders => _listingProviders; - - private LiveTvOptions GetConfiguration() - { - return _config.GetConfiguration("livetv"); - } - - public string GetEmbyTvActiveRecordingPath(string id) - { - return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); - } - - /// - /// Adds the parts. - /// - /// The services. - /// The tuner hosts. - /// The listing providers. - public void AddParts(IEnumerable services, IEnumerable tunerHosts, IEnumerable listingProviders) - { - _services = services.ToArray(); - _tunerHosts = tunerHosts.Where(i => i.IsSupported).ToArray(); - - _listingProviders = listingProviders.ToArray(); - - foreach (var service in _services) - { - if (service is EmbyTV.EmbyTV embyTv) - { - embyTv.TimerCreated += OnEmbyTvTimerCreated; - embyTv.TimerCancelled += OnEmbyTvTimerCancelled; - } - } - } - - private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs e) - { - var timerId = e.Argument; - - TimerCancelled?.Invoke(this, new GenericEventArgs(new TimerEventInfo(timerId))); - } - - private void OnEmbyTvTimerCreated(object sender, GenericEventArgs e) - { - var timer = e.Argument; - - TimerCreated?.Invoke(this, new GenericEventArgs( - new TimerEventInfo(timer.Id) - { - ProgramId = _tvDtoService.GetInternalProgramId(timer.ProgramId) - })); - } - - public List GetTunerHostTypes() - { - return _tunerHosts.OrderBy(i => i.Name).Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Type - }).ToList(); - } - - public Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) - { - return EmbyTV.EmbyTV.Current.DiscoverTuners(newDevicesOnly, cancellationToken); - } - - public QueryResult GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken) - { - var user = query.UserId.Equals(default) - ? null - : _userManager.GetUserById(query.UserId); - - var topFolder = GetInternalLiveTvFolder(cancellationToken); - - var internalQuery = new InternalItemsQuery(user) - { - IsMovie = query.IsMovie, - IsNews = query.IsNews, - IsKids = query.IsKids, - IsSports = query.IsSports, - IsSeries = query.IsSeries, - IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, - TopParentIds = new[] { topFolder.Id }, - IsFavorite = query.IsFavorite, - IsLiked = query.IsLiked, - StartIndex = query.StartIndex, - Limit = query.Limit, - DtoOptions = dtoOptions - }; - - var orderBy = internalQuery.OrderBy.ToList(); - - orderBy.AddRange(query.SortBy.Select(i => (i, query.SortOrder ?? SortOrder.Ascending))); - - if (query.EnableFavoriteSorting) - { - orderBy.Insert(0, (ItemSortBy.IsFavoriteOrLiked, SortOrder.Descending)); - } - - if (internalQuery.OrderBy.All(i => i.OrderBy != ItemSortBy.SortName)) - { - orderBy.Add((ItemSortBy.SortName, SortOrder.Ascending)); - } - - internalQuery.OrderBy = orderBy.ToArray(); - - return _libraryManager.GetItemsResult(internalQuery); - } - - public async Task> GetChannelStream(string id, string mediaSourceId, List currentLiveStreams, CancellationToken cancellationToken) - { - if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) - { - mediaSourceId = null; - } - - var channel = (LiveTvChannel)_libraryManager.GetItemById(id); - - bool isVideo = channel.ChannelType == ChannelType.TV; - var service = GetService(channel); - _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId); - - MediaSourceInfo info; - ILiveStream liveStream; - if (service is ISupportsDirectStreamProvider supportsManagedStream) - { - liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); - info = liveStream.MediaSource; - } - else - { - info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false); - var openedId = info.Id; - Func closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None); - - liveStream = new ExclusiveLiveStream(info, closeFn); - - var startTime = DateTime.UtcNow; - await liveStream.Open(cancellationToken).ConfigureAwait(false); - var endTime = DateTime.UtcNow; - _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds); - } - - info.RequiresClosing = true; - - var idPrefix = service.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_"; - - info.LiveStreamId = idPrefix + info.Id; - - Normalize(info, service, isVideo); - - return new Tuple(info, liveStream); - } - - public async Task> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken) - { - var baseItem = (LiveTvChannel)item; - var service = GetService(baseItem); - - var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false); - - if (sources.Count == 0) - { - throw new NotImplementedException(); - } - - foreach (var source in sources) - { - Normalize(source, service, baseItem.ChannelType == ChannelType.TV); - } - - return sources; - } - - private ILiveTvService GetService(LiveTvChannel item) - { - var name = item.ServiceName; - return GetService(name); - } - - private ILiveTvService GetService(LiveTvProgram item) - { - var channel = _libraryManager.GetItemById(item.ChannelId) as LiveTvChannel; - - return GetService(channel); - } - - private ILiveTvService GetService(string name) - => Array.Find(_services, x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)) - ?? throw new KeyNotFoundException( - string.Format( - CultureInfo.InvariantCulture, - "No service with the name '{0}' can be found.", - name)); - - private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo) - { - // Not all of the plugins are setting this - mediaSource.IsInfiniteStream = true; - - if (mediaSource.MediaStreams.Count == 0) - { - if (isVideo) - { - mediaSource.MediaStreams = new MediaStream[] - { - new MediaStream - { - Type = MediaStreamType.Video, - // Set the index to -1 because we don't know the exact index of the video stream within the container - Index = -1, - - // Set to true if unknown to enable deinterlacing - IsInterlaced = true - }, - new MediaStream - { - Type = MediaStreamType.Audio, - // Set the index to -1 because we don't know the exact index of the audio stream within the container - Index = -1 - } - }; - } - else - { - mediaSource.MediaStreams = new MediaStream[] - { - new MediaStream - { - Type = MediaStreamType.Audio, - // Set the index to -1 because we don't know the exact index of the audio stream within the container - Index = -1 - } - }; - } - } - - // Clean some bad data coming from providers - foreach (var stream in mediaSource.MediaStreams) - { - if (stream.BitRate.HasValue && stream.BitRate <= 0) - { - stream.BitRate = null; - } - - if (stream.Channels.HasValue && stream.Channels <= 0) - { - stream.Channels = null; - } - - if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0) - { - stream.AverageFrameRate = null; - } - - if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0) - { - stream.RealFrameRate = null; - } - - if (stream.Width.HasValue && stream.Width <= 0) - { - stream.Width = null; - } - - if (stream.Height.HasValue && stream.Height <= 0) - { - stream.Height = null; - } - - if (stream.SampleRate.HasValue && stream.SampleRate <= 0) - { - stream.SampleRate = null; - } - - if (stream.Level.HasValue && stream.Level <= 0) - { - stream.Level = null; - } - } - - var indexes = mediaSource.MediaStreams.Select(i => i.Index).Distinct().ToList(); - - // If there are duplicate stream indexes, set them all to unknown - if (indexes.Count != mediaSource.MediaStreams.Count) - { - foreach (var stream in mediaSource.MediaStreams) - { - stream.Index = -1; - } - } - - // Set the total bitrate if not already supplied - mediaSource.InferTotalBitrate(); - - if (service is not EmbyTV.EmbyTV) - { - // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says - // mediaSource.SupportsDirectPlay = false; - // mediaSource.SupportsDirectStream = false; - mediaSource.SupportsTranscoding = true; - foreach (var stream in mediaSource.MediaStreams) - { - if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize)) - { - stream.NalLengthSize = "0"; - } - - if (stream.Type == MediaStreamType.Video) - { - stream.IsInterlaced = true; - } - } - } - } - - private async Task GetChannelAsync(ChannelInfo channelInfo, string serviceName, BaseItem parentFolder, CancellationToken cancellationToken) - { - var parentFolderId = parentFolder.Id; - var isNew = false; - var forceUpdate = false; - - var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id); - - var item = _libraryManager.GetItemById(id) as LiveTvChannel; - - if (item is null) - { - item = new LiveTvChannel - { - Name = channelInfo.Name, - Id = id, - DateCreated = DateTime.UtcNow - }; - - isNew = true; - } - - if (channelInfo.Tags is not null) - { - if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase)) - { - isNew = true; - } - - item.Tags = channelInfo.Tags; - } - - if (!item.ParentId.Equals(parentFolderId)) - { - isNew = true; - } - - item.ParentId = parentFolderId; - - item.ChannelType = channelInfo.ChannelType; - item.ServiceName = serviceName; - - if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase)) - { - forceUpdate = true; - } - - item.SetProviderId(ExternalServiceTag, serviceName); - - if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.ExternalId = channelInfo.Id; - - if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.Number = channelInfo.Number; - - if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.Name = channelInfo.Name; - - if (!item.HasImage(ImageType.Primary)) - { - if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); - forceUpdate = true; - } - else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); - forceUpdate = true; - } - } - - if (isNew) - { - _libraryManager.CreateItem(item, parentFolder); - } - else if (forceUpdate) - { - await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - } - - return item; - } - - private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(ProgramInfo info, Dictionary allExistingPrograms, LiveTvChannel channel) - { - var id = _tvDtoService.GetInternalProgramId(info.Id); - - var isNew = false; - var forceUpdate = false; - - if (!allExistingPrograms.TryGetValue(id, out LiveTvProgram item)) - { - isNew = true; - item = new LiveTvProgram - { - Name = info.Name, - Id = id, - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow - }; - - if (!string.IsNullOrEmpty(info.Etag)) - { - item.SetProviderId(EtagKey, info.Etag); - } - } - - if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) - { - item.ShowId = info.ShowId; - forceUpdate = true; - } - - var seriesId = info.SeriesId; - - if (!item.ParentId.Equals(channel.Id)) - { - forceUpdate = true; - } - - item.ParentId = channel.Id; - - item.Audio = info.Audio; - item.ChannelId = channel.Id; - item.CommunityRating ??= info.CommunityRating; - if ((item.CommunityRating ?? 0).Equals(0)) - { - item.CommunityRating = null; - } - - item.EpisodeTitle = info.EpisodeTitle; - item.ExternalId = info.Id; - - if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.ExternalSeriesId = seriesId; - - var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); - - if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle)) - { - item.SeriesName = info.Name; - } - - var tags = new List(); - if (info.IsLive) - { - tags.Add("Live"); - } - - if (info.IsPremiere) - { - tags.Add("Premiere"); - } - - if (info.IsNews) - { - tags.Add("News"); - } - - if (info.IsSports) - { - tags.Add("Sports"); - } - - if (info.IsKids) - { - tags.Add("Kids"); - } - - if (info.IsRepeat) - { - tags.Add("Repeat"); - } - - if (info.IsMovie) - { - tags.Add("Movie"); - } - - if (isSeries) - { - tags.Add("Series"); - } - - item.Tags = tags.ToArray(); - - item.Genres = info.Genres.ToArray(); - - if (info.IsHD ?? false) - { - item.Width = 1280; - item.Height = 720; - } - - item.IsMovie = info.IsMovie; - item.IsRepeat = info.IsRepeat; - - if (item.IsSeries != isSeries) - { - forceUpdate = true; - } - - item.IsSeries = isSeries; - - item.Name = info.Name; - item.OfficialRating ??= info.OfficialRating; - item.Overview ??= info.Overview; - item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; - item.ProviderIds = info.ProviderIds; - - foreach (var providerId in info.SeriesProviderIds) - { - info.ProviderIds["Series" + providerId.Key] = providerId.Value; - } - - if (item.StartDate != info.StartDate) - { - forceUpdate = true; - } - - item.StartDate = info.StartDate; - - if (item.EndDate != info.EndDate) - { - forceUpdate = true; - } - - item.EndDate = info.EndDate; - - item.ProductionYear = info.ProductionYear; - - if (!isSeries || info.IsRepeat) - { - item.PremiereDate = info.OriginalAirDate; - } - - item.IndexNumber = info.EpisodeNumber; - item.ParentIndexNumber = info.SeasonNumber; - - if (!item.HasImage(ImageType.Primary)) - { - if (!string.IsNullOrWhiteSpace(info.ImagePath)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImagePath, - Type = ImageType.Primary - }, - 0); - } - else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImageUrl, - Type = ImageType.Primary - }, - 0); - } - } - - if (!item.HasImage(ImageType.Thumb)) - { - if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ThumbImageUrl, - Type = ImageType.Thumb - }, - 0); - } - } - - if (!item.HasImage(ImageType.Logo)) - { - if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.LogoImageUrl, - Type = ImageType.Logo - }, - 0); - } - } - - if (!item.HasImage(ImageType.Backdrop)) - { - if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.BackdropImageUrl, - Type = ImageType.Backdrop - }, - 0); - } - } - - var isUpdated = false; - if (isNew) - { - } - else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) - { - isUpdated = true; - } - else - { - var etag = info.Etag; - - if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) - { - item.SetProviderId(EtagKey, etag); - isUpdated = true; - } - } - - if (isNew || isUpdated) - { - item.OnMetadataChanged(); - } - - return (item, isNew, isUpdated); - } - - public async Task GetProgram(string id, CancellationToken cancellationToken, User user = null) - { - var program = _libraryManager.GetItemById(id); - - var dto = _dtoService.GetBaseItemDto(program, new DtoOptions(), user); - - var list = new List<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)> - { - (dto, program.ExternalId, program.ExternalSeriesId) - }; - - await AddRecordingInfo(list, cancellationToken).ConfigureAwait(false); - - return dto; - } - - public async Task> GetPrograms(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) - { - var user = query.User; - - var topFolder = GetInternalLiveTvFolder(cancellationToken); - - if (query.OrderBy.Count == 0) - { - // Unless something else was specified, order by start date to take advantage of a specialized index - query.OrderBy = new[] - { - (ItemSortBy.StartDate, SortOrder.Ascending) - }; - } - - RemoveFields(options); - - var internalQuery = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - MinEndDate = query.MinEndDate, - MinStartDate = query.MinStartDate, - MaxEndDate = query.MaxEndDate, - MaxStartDate = query.MaxStartDate, - ChannelIds = query.ChannelIds, - IsMovie = query.IsMovie, - IsSeries = query.IsSeries, - IsSports = query.IsSports, - IsKids = query.IsKids, - IsNews = query.IsNews, - Genres = query.Genres, - GenreIds = query.GenreIds, - StartIndex = query.StartIndex, - Limit = query.Limit, - OrderBy = query.OrderBy, - EnableTotalRecordCount = query.EnableTotalRecordCount, - TopParentIds = new[] { topFolder.Id }, - Name = query.Name, - DtoOptions = options, - HasAired = query.HasAired, - IsAiring = query.IsAiring - }; - - if (!string.IsNullOrWhiteSpace(query.SeriesTimerId)) - { - var seriesTimers = await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false); - var seriesTimer = seriesTimers.Items.FirstOrDefault(i => string.Equals(_tvDtoService.GetInternalSeriesTimerId(i.Id).ToString("N", CultureInfo.InvariantCulture), query.SeriesTimerId, StringComparison.OrdinalIgnoreCase)); - if (seriesTimer is not null) - { - internalQuery.ExternalSeriesId = seriesTimer.SeriesId; - - if (string.IsNullOrWhiteSpace(seriesTimer.SeriesId)) - { - // Better to return nothing than every program in the database - return new QueryResult(); - } - } - else - { - // Better to return nothing than every program in the database - return new QueryResult(); - } - } - - var queryResult = _libraryManager.QueryItems(internalQuery); - - var returnArray = _dtoService.GetBaseItemDtos(queryResult.Items, options, user); - - return new QueryResult( - query.StartIndex, - queryResult.TotalRecordCount, - returnArray); - } - - public QueryResult GetRecommendedProgramsInternal(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) - { - var user = query.User; - - var topFolder = GetInternalLiveTvFolder(cancellationToken); - - var internalQuery = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - IsAiring = query.IsAiring, - HasAired = query.HasAired, - IsNews = query.IsNews, - IsMovie = query.IsMovie, - IsSeries = query.IsSeries, - IsSports = query.IsSports, - IsKids = query.IsKids, - EnableTotalRecordCount = query.EnableTotalRecordCount, - OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) }, - TopParentIds = new[] { topFolder.Id }, - DtoOptions = options, - GenreIds = query.GenreIds - }; - - if (query.Limit.HasValue) - { - internalQuery.Limit = Math.Max(query.Limit.Value * 4, 200); - } - - var programList = _libraryManager.QueryItems(internalQuery).Items; - var totalCount = programList.Count; - - var orderedPrograms = programList.Cast().OrderBy(i => i.StartDate.Date); - - if (query.IsAiring ?? false) - { - orderedPrograms = orderedPrograms - .ThenByDescending(i => GetRecommendationScore(i, user, true)); - } - - IEnumerable programs = orderedPrograms; - - if (query.Limit.HasValue) - { - programs = programs.Take(query.Limit.Value); - } - - return new QueryResult( - query.StartIndex, - totalCount, - programs.ToArray()); - } - - public Task> GetRecommendedProgramsAsync(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) - { - if (!(query.IsAiring ?? false)) - { - return GetPrograms(query, options, cancellationToken); - } - - RemoveFields(options); - - var internalResult = GetRecommendedProgramsInternal(query, options, cancellationToken); - - return Task.FromResult(new QueryResult( - query.StartIndex, - internalResult.TotalRecordCount, - _dtoService.GetBaseItemDtos(internalResult.Items, options, query.User))); - } - - private int GetRecommendationScore(LiveTvProgram program, User user, bool factorChannelWatchCount) - { - var score = 0; - - if (program.IsLive) - { - score++; - } - - if (program.IsSeries && !program.IsRepeat) - { - score++; - } - - var channel = _libraryManager.GetItemById(program.ChannelId); - - if (channel is null) - { - return score; - } - - var channelUserdata = _userDataManager.GetUserData(user, channel); - - if (channelUserdata.Likes.HasValue) - { - score += channelUserdata.Likes.Value ? 2 : -2; - } - - if (channelUserdata.IsFavorite) - { - score += 3; - } - - if (factorChannelWatchCount) - { - score += channelUserdata.PlayCount; - } - - return score; - } - - private async Task AddRecordingInfo(IEnumerable<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)> programs, CancellationToken cancellationToken) - { - IReadOnlyList timerList = null; - IReadOnlyList seriesTimerList = null; - - foreach (var programTuple in programs) - { - var program = programTuple.ItemDto; - var externalProgramId = programTuple.ExternalId; - string externalSeriesId = programTuple.ExternalSeriesId; - - timerList ??= (await GetTimersInternal(new TimerQuery(), cancellationToken).ConfigureAwait(false)).Items; - - var timer = timerList.FirstOrDefault(i => string.Equals(i.ProgramId, externalProgramId, StringComparison.OrdinalIgnoreCase)); - var foundSeriesTimer = false; - - if (timer is not null) - { - if (timer.Status != RecordingStatus.Cancelled && timer.Status != RecordingStatus.Error) - { - program.TimerId = _tvDtoService.GetInternalTimerId(timer.Id); - - program.Status = timer.Status.ToString(); - } - - if (!string.IsNullOrEmpty(timer.SeriesTimerId)) - { - program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(timer.SeriesTimerId) - .ToString("N", CultureInfo.InvariantCulture); - - foundSeriesTimer = true; - } - } - - if (foundSeriesTimer || string.IsNullOrWhiteSpace(externalSeriesId)) - { - continue; - } - - seriesTimerList ??= (await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false)).Items; - - var seriesTimer = seriesTimerList.FirstOrDefault(i => string.Equals(i.SeriesId, externalSeriesId, StringComparison.OrdinalIgnoreCase)); - - if (seriesTimer is not null) - { - program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(seriesTimer.Id) - .ToString("N", CultureInfo.InvariantCulture); - } - } - } - - internal Task RefreshChannels(IProgress progress, CancellationToken cancellationToken) - { - return RefreshChannelsInternal(progress, cancellationToken); - } - - private async Task RefreshChannelsInternal(IProgress progress, CancellationToken cancellationToken) - { - await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); - - await EmbyTV.EmbyTV.Current.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); - - var numComplete = 0; - double progressPerService = _services.Length == 0 - ? 0 - : 1.0 / _services.Length; - - var newChannelIdList = new List(); - var newProgramIdList = new List(); - - var cleanDatabase = true; - - foreach (var service in _services) - { - cancellationToken.ThrowIfCancellationRequested(); - - _logger.LogDebug("Refreshing guide from {Name}", service.Name); - - try - { - var innerProgress = new ActionableProgress(); - innerProgress.RegisterAction(p => progress.Report(p * progressPerService)); - - var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); - - newChannelIdList.AddRange(idList.Item1); - newProgramIdList.AddRange(idList.Item2); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - cleanDatabase = false; - _logger.LogError(ex, "Error refreshing channels for service"); - } - - numComplete++; - double percent = numComplete; - percent /= _services.Length; - - progress.Report(100 * percent); - } - - if (cleanDatabase) - { - CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { BaseItemKind.LiveTvChannel }, progress, cancellationToken); - CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { BaseItemKind.LiveTvProgram }, progress, cancellationToken); - } - - var coreService = _services.OfType().FirstOrDefault(); - - if (coreService is not null) - { - await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); - await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); - } - - // Load these now which will prefetch metadata - var dtoOptions = new DtoOptions(); - var fields = dtoOptions.Fields.ToList(); - dtoOptions.Fields = fields.ToArray(); - - progress.Report(100); - } - - private async Task, List>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress progress, CancellationToken cancellationToken) - { - progress.Report(10); - - var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) - .Select(i => new Tuple(service.Name, i)) - .ToList(); - - var list = new List(); - - var numComplete = 0; - var parentFolder = GetInternalLiveTvFolder(cancellationToken); - - foreach (var channelInfo in allChannelsList) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var item = await GetChannelAsync(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false); - - list.Add(item); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); - } - - numComplete++; - double percent = numComplete; - percent /= allChannelsList.Count; - - progress.Report((5 * percent) + 10); - } - - progress.Report(15); - - numComplete = 0; - var programs = new List(); - var channels = new List(); - - var guideDays = GetGuideDays(); - - _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); - - cancellationToken.ThrowIfCancellationRequested(); - - foreach (var currentChannel in list) - { - channels.Add(currentChannel.Id); - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var start = DateTime.UtcNow.AddHours(-1); - var end = start.AddDays(guideDays); - - var isMovie = false; - var isSports = false; - var isNews = false; - var isKids = false; - var iSSeries = false; - - var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList(); - - var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ChannelIds = new Guid[] { currentChannel.Id }, - DtoOptions = new DtoOptions(true) - }).Cast().ToDictionary(i => i.Id); - - var newPrograms = new List(); - var updatedPrograms = new List(); - - foreach (var program in channelPrograms) - { - var programTuple = GetProgram(program, existingPrograms, currentChannel); - var programItem = programTuple.Item; - - if (programTuple.IsNew) - { - newPrograms.Add(programItem); - } - else if (programTuple.IsUpdated) - { - updatedPrograms.Add(programItem); - } - - programs.Add(programItem.Id); - - isMovie |= program.IsMovie; - iSSeries |= program.IsSeries; - isSports |= program.IsSports; - isNews |= program.IsNews; - isKids |= program.IsKids; - } - - _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); - - if (newPrograms.Count > 0) - { - _libraryManager.CreateItems(newPrograms, null, cancellationToken); - } - - if (updatedPrograms.Count > 0) - { - await _libraryManager.UpdateItemsAsync( - updatedPrograms, - currentChannel, - ItemUpdateType.MetadataImport, - cancellationToken).ConfigureAwait(false); - } - - currentChannel.IsMovie = isMovie; - currentChannel.IsNews = isNews; - currentChannel.IsSports = isSports; - currentChannel.IsSeries = iSSeries; - - if (isKids) - { - currentChannel.AddTag("Kids"); - } - - await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - await currentChannel.RefreshMetadata( - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - ForceSave = true - }, - cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); - } - - numComplete++; - double percent = numComplete / (double)allChannelsList.Count; - - progress.Report((85 * percent) + 15); - } - - progress.Report(100); - return new Tuple, List>(channels, programs); - } - - private void CleanDatabaseInternal(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress progress, CancellationToken cancellationToken) - { - var list = _itemRepo.GetItemIdsList(new InternalItemsQuery - { - IncludeItemTypes = validTypes, - DtoOptions = new DtoOptions(false) - }); - - var numComplete = 0; - - foreach (var itemId in list) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (itemId.Equals(default)) - { - // Somehow some invalid data got into the db. It probably predates the boundary checking - continue; - } - - if (!currentIdList.Contains(itemId)) - { - var item = _libraryManager.GetItemById(itemId); - - if (item is not null) - { - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = false, - DeleteFromExternalProvider = false - }, - false); - } - } - - numComplete++; - double percent = numComplete / (double)list.Count; - - progress.Report(100 * percent); - } - } - - private double GetGuideDays() - { - var config = GetConfiguration(); - - if (config.GuideDays.HasValue) - { - return Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays)); - } - - return 7; - } - - private async Task> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user) - { - if (user is null) - { - return new QueryResult(); - } - - var folders = await GetRecordingFoldersAsync(user, true).ConfigureAwait(false); - var folderIds = Array.ConvertAll(folders, x => x.Id); - - var excludeItemTypes = new List(); - - if (folderIds.Length == 0) - { - return new QueryResult(); - } - - var includeItemTypes = new List(); - var genres = new List(); - - if (query.IsMovie.HasValue) - { - if (query.IsMovie.Value) - { - includeItemTypes.Add(BaseItemKind.Movie); - } - else - { - excludeItemTypes.Add(BaseItemKind.Movie); - } - } - - if (query.IsSeries.HasValue) - { - if (query.IsSeries.Value) - { - includeItemTypes.Add(BaseItemKind.Episode); - } - else - { - excludeItemTypes.Add(BaseItemKind.Episode); - } - } - - if (query.IsSports ?? false) - { - genres.Add("Sports"); - } - - if (query.IsKids ?? false) - { - genres.Add("Kids"); - genres.Add("Children"); - genres.Add("Family"); - } - - var limit = query.Limit; - - if (query.IsInProgress ?? false) - { - // limit = (query.Limit ?? 10) * 2; - limit = null; - - // var allActivePaths = EmbyTV.EmbyTV.Current.GetAllActiveRecordings().Select(i => i.Path).ToArray(); - // var items = allActivePaths.Select(i => _libraryManager.FindByPath(i, false)).Where(i => i is not null).ToArray(); - - // return new QueryResult - // { - // Items = items, - // TotalRecordCount = items.Length - // }; - - dtoOptions.Fields = dtoOptions.Fields.Concat(new[] { ItemFields.Tags }).Distinct().ToArray(); - } - - var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) - { - MediaTypes = new[] { MediaType.Video }, - Recursive = true, - AncestorIds = folderIds, - IsFolder = false, - IsVirtualItem = false, - Limit = limit, - StartIndex = query.StartIndex, - OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, - EnableTotalRecordCount = query.EnableTotalRecordCount, - IncludeItemTypes = includeItemTypes.ToArray(), - ExcludeItemTypes = excludeItemTypes.ToArray(), - Genres = genres.ToArray(), - DtoOptions = dtoOptions - }); - - if (query.IsInProgress ?? false) - { - // TODO: Fix The co-variant conversion between Video[] and BaseItem[], this can generate runtime issues. - result.Items = result - .Items - .OfType diff --git a/Jellyfin.sln b/Jellyfin.sln index 31e302d944..30eab6cc21 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -89,6 +89,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.MediaEncoding.Keyf EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv.Tests", "tests\Jellyfin.LiveTv.Tests\Jellyfin.LiveTv.Tests.csproj", "{C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv", "src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj", "{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -239,6 +241,10 @@ Global {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Debug|Any CPU.Build.0 = Debug|Any CPU {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Release|Any CPU.ActiveCfg = Release|Any CPU {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Release|Any CPU.Build.0 = Release|Any CPU + {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -266,6 +272,7 @@ Global {154872D9-6C12-4007-96E3-8F70A58386CE} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {0A3FCC4D-C714-4072-B90F-E374A15F9FF9} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE} diff --git a/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs b/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs new file mode 100644 index 0000000000..2a25218b63 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs @@ -0,0 +1,118 @@ +#pragma warning disable CS1591 + +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Streaming; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public sealed class DirectRecorder : IRecorder + { + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IStreamHelper _streamHelper; + + public DirectRecorder(ILogger logger, IHttpClientFactory httpClientFactory, IStreamHelper streamHelper) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _streamHelper = streamHelper; + } + + public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) + { + return targetFile; + } + + public Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + if (directStreamProvider is not null) + { + return RecordFromDirectStreamProvider(directStreamProvider, targetFile, duration, onStarted, cancellationToken); + } + + return RecordFromMediaSource(mediaSource, targetFile, duration, onStarted, cancellationToken); + } + + private async Task RecordFromDirectStreamProvider(IDirectStreamProvider directStreamProvider, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); + + var output = new FileStream( + targetFile, + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous); + + await using (output.ConfigureAwait(false)) + { + onStarted(); + + _logger.LogInformation("Copying recording to file {FilePath}", targetFile); + + // The media source is infinite so we need to handle stopping ourselves + using var durationToken = new CancellationTokenSource(duration); + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + var linkedCancellationToken = cancellationTokenSource.Token; + var fileStream = new ProgressiveFileStream(directStreamProvider.GetStream()); + await using (fileStream.ConfigureAwait(false)) + { + await _streamHelper.CopyToAsync( + fileStream, + output, + IODefaults.CopyToBufferSize, + 1000, + linkedCancellationToken).ConfigureAwait(false); + } + } + + _logger.LogInformation("Recording completed: {FilePath}", targetFile); + } + + private async Task RecordFromMediaSource(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(mediaSource.Path, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Opened recording stream from tuner provider"); + + Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); + + var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.CopyToBufferSize, FileOptions.Asynchronous); + await using (output.ConfigureAwait(false)) + { + onStarted(); + + _logger.LogInformation("Copying recording stream to file {0}", targetFile); + + // The media source if infinite so we need to handle stopping ourselves + using var durationToken = new CancellationTokenSource(duration); + using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + cancellationToken = linkedCancellationToken.Token; + + await _streamHelper.CopyUntilCancelled( + await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), + output, + IODefaults.CopyToBufferSize, + cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Recording completed to file {0}", targetFile); + } + } + + /// + public void Dispose() + { + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs new file mode 100644 index 0000000000..439ed965b0 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -0,0 +1,2621 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Jellyfin.Data.Enums; +using Jellyfin.Data.Events; +using Jellyfin.Extensions; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable + { + public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; + + private const int TunerDiscoveryDurationMs = 3000; + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IServerConfigurationManager _config; + + private readonly ItemDataProvider _seriesTimerProvider; + private readonly TimerManager _timerProvider; + + private readonly LiveTvManager _liveTvManager; + private readonly IFileSystem _fileSystem; + + private readonly ILibraryMonitor _libraryMonitor; + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IStreamHelper _streamHelper; + + private readonly ConcurrentDictionary _activeRecordings = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary _epgChannels = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); + + private bool _disposed; + + public EmbyTV( + IStreamHelper streamHelper, + IMediaSourceManager mediaSourceManager, + ILogger logger, + IHttpClientFactory httpClientFactory, + IServerConfigurationManager config, + ILiveTvManager liveTvManager, + IFileSystem fileSystem, + ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor, + IProviderManager providerManager, + IMediaEncoder mediaEncoder) + { + Current = this; + + _logger = logger; + _httpClientFactory = httpClientFactory; + _config = config; + _fileSystem = fileSystem; + _libraryManager = libraryManager; + _libraryMonitor = libraryMonitor; + _providerManager = providerManager; + _mediaEncoder = mediaEncoder; + _liveTvManager = (LiveTvManager)liveTvManager; + _mediaSourceManager = mediaSourceManager; + _streamHelper = streamHelper; + + _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); + _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); + _timerProvider.TimerFired += OnTimerProviderTimerFired; + + _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; + } + + public event EventHandler> TimerCreated; + + public event EventHandler> TimerCancelled; + + public static EmbyTV Current { get; private set; } + + /// + public string Name => "Emby"; + + public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); + + /// + public string HomePageUrl => "https://github.com/jellyfin/jellyfin"; + + private string DefaultRecordingPath => Path.Combine(DataPath, "recordings"); + + private string RecordingPath + { + get + { + var path = GetConfiguration().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(); + + 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 = GetConfiguration(); + + var pathsToRemove = config.MediaLocationsCreated + .Except(recordingFolders.SelectMany(i => i.Locations)) + .ToList(); + + if (pathsAdded.Count > 0 || pathsToRemove.Count > 0) + { + pathsAdded.InsertRange(0, config.MediaLocationsCreated); + config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + _config.SaveConfiguration("livetv", config); + } + + foreach (var path in pathsToRemove) + { + await RemovePathFromLibraryAsync(path).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating recording folders"); + } + } + + private async Task RemovePathFromLibraryAsync(string path) + { + _logger.LogDebug("Removing path from library: {0}", path); + + var requiresRefresh = false; + var virtualFolders = _libraryManager.GetVirtualFolders(); + + foreach (var virtualFolder in virtualFolders) + { + if (!virtualFolder.Locations.Contains(path, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (virtualFolder.Locations.Length == 1) + { + // remove entire virtual folder + try + { + await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing virtual folder"); + } + } + else + { + try + { + _libraryManager.RemoveMediaPath(virtualFolder.Name, path); + requiresRefresh = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing media path"); + } + } + } + + if (requiresRefresh) + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); + } + } + + public async Task RefreshSeriesTimers(CancellationToken cancellationToken) + { + var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); + + foreach (var timer in seriesTimers) + { + UpdateTimersForSeriesTimer(timer, false, true); + } + } + + public async Task RefreshTimers(CancellationToken cancellationToken) + { + var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false); + + var tempChannelCache = new Dictionary(); + + foreach (var timer in timers) + { + if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id)) + { + OnTimerOutOfDate(timer); + continue; + } + + if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId)) + { + continue; + } + + var program = GetProgramInfoFromCache(timer); + if (program is null) + { + OnTimerOutOfDate(timer); + continue; + } + + CopyProgramInfoToTimerInfo(program, timer, tempChannelCache); + _timerProvider.Update(timer); + } + } + + private void OnTimerOutOfDate(TimerInfo timer) + { + _timerProvider.Delete(timer); + } + + private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) + { + var list = new List(); + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); + + list.AddRange(channels); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channels"); + } + } + + foreach (var provider in GetListingProviders()) + { + var enabledChannels = list + .Where(i => IsListingProviderEnabledForTuner(provider.Item2, i.TunerHostId)) + .ToList(); + + if (enabledChannels.Count > 0) + { + try + { + await AddMetadata(provider.Item1, provider.Item2, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false); + } + catch (NotSupportedException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding metadata"); + } + } + } + + return list; + } + + private async Task AddMetadata( + IListingsProvider provider, + ListingsProviderInfo info, + IEnumerable tunerChannels, + bool enableCache, + CancellationToken cancellationToken) + { + var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false); + + foreach (var tunerChannel in tunerChannels) + { + var epgChannel = GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels); + + if (epgChannel is not null) + { + if (!string.IsNullOrWhiteSpace(epgChannel.Name)) + { + // tunerChannel.Name = epgChannel.Name; + } + + if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl)) + { + tunerChannel.ImageUrl = epgChannel.ImageUrl; + } + } + } + } + + private async Task 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 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 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> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken) + { + var list = new List(); + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + var channels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false); + + list.AddRange(channels); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channels"); + } + } + + return list + .Where(i => IsListingProviderEnabledForTuner(listingsProvider, i.TunerHostId)) + .ToList(); + } + + public Task> GetChannelsAsync(CancellationToken cancellationToken) + { + return GetChannelsAsync(false, cancellationToken); + } + + public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) + { + var timers = _timerProvider + .GetAll() + .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var timer in timers) + { + CancelTimerInternal(timer.Id, true, true); + } + + var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); + if (remove is not null) + { + _seriesTimerProvider.Delete(remove); + } + + return Task.CompletedTask; + } + + private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation) + { + var timer = _timerProvider.GetTimer(timerId); + if (timer is not null) + { + var statusChanging = timer.Status != RecordingStatus.Cancelled; + timer.Status = RecordingStatus.Cancelled; + + if (isManualCancellation) + { + timer.IsManual = true; + } + + if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled) + { + _timerProvider.Delete(timer); + } + else + { + _timerProvider.AddOrUpdate(timer, false); + } + + if (statusChanging && TimerCancelled is not null) + { + TimerCancelled(this, new GenericEventArgs(timerId)); + } + } + + if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo)) + { + activeRecordingInfo.Timer = timer; + activeRecordingInfo.CancellationTokenSource.Cancel(); + } + } + + public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) + { + CancelTimerInternal(timerId, false, true); + return Task.CompletedTask; + } + + public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task CreateTimer(TimerInfo info, CancellationToken cancellationToken) + { + var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ? + null : + _timerProvider.GetTimerByProgramId(info.ProgramId); + + if (existingTimer is not null) + { + if (existingTimer.Status == RecordingStatus.Cancelled + || existingTimer.Status == RecordingStatus.Completed) + { + existingTimer.Status = RecordingStatus.New; + existingTimer.IsManual = true; + _timerProvider.Update(existingTimer); + return Task.FromResult(existingTimer.Id); + } + + throw new ArgumentException("A scheduled recording already exists for this program."); + } + + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + + LiveTvProgram programInfo = null; + + if (!string.IsNullOrWhiteSpace(info.ProgramId)) + { + programInfo = GetProgramInfoFromCache(info); + } + + if (programInfo is null) + { + _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId); + programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate); + } + + if (programInfo is not null) + { + CopyProgramInfoToTimerInfo(programInfo, info); + } + + info.IsManual = true; + _timerProvider.Add(info); + + TimerCreated?.Invoke(this, new GenericEventArgs(info)); + + return Task.FromResult(info.Id); + } + + public async Task CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken) + { + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + + // populate info.seriesID + var program = GetProgramInfoFromCache(info.ProgramId); + + if (program is not null) + { + info.SeriesId = program.ExternalSeriesId; + } + else + { + throw new InvalidOperationException("SeriesId for program not found"); + } + + // If any timers have already been manually created, make sure they don't get cancelled + var existingTimers = (await GetTimersAsync(CancellationToken.None).ConfigureAwait(false)) + .Where(i => + { + if (string.Equals(i.ProgramId, info.ProgramId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.ProgramId)) + { + return true; + } + + if (string.Equals(i.SeriesId, info.SeriesId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.SeriesId)) + { + return true; + } + + return false; + }) + .ToList(); + + _seriesTimerProvider.Add(info); + + foreach (var timer in existingTimers) + { + timer.SeriesTimerId = info.Id; + timer.IsManual = true; + + _timerProvider.AddOrUpdate(timer, false); + } + + UpdateTimersForSeriesTimer(info, true, false); + + return info.Id; + } + + public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); + + if (instance is not null) + { + instance.ChannelId = info.ChannelId; + instance.Days = info.Days; + instance.EndDate = info.EndDate; + instance.IsPostPaddingRequired = info.IsPostPaddingRequired; + instance.IsPrePaddingRequired = info.IsPrePaddingRequired; + instance.PostPaddingSeconds = info.PostPaddingSeconds; + instance.PrePaddingSeconds = info.PrePaddingSeconds; + instance.Priority = info.Priority; + instance.RecordAnyChannel = info.RecordAnyChannel; + instance.RecordAnyTime = info.RecordAnyTime; + instance.RecordNewOnly = info.RecordNewOnly; + instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary; + instance.KeepUpTo = info.KeepUpTo; + instance.KeepUntil = info.KeepUntil; + instance.StartDate = info.StartDate; + + _seriesTimerProvider.Update(instance); + + UpdateTimersForSeriesTimer(instance, true, true); + } + + return Task.CompletedTask; + } + + public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) + { + var existingTimer = _timerProvider.GetTimer(updatedTimer.Id); + + if (existingTimer is null) + { + throw new ResourceNotFoundException(); + } + + // Only update if not currently active + if (!_activeRecordings.TryGetValue(updatedTimer.Id, out _)) + { + existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; + existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; + existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired; + existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired; + + _timerProvider.Update(existingTimer); + } + + return Task.CompletedTask; + } + + private static void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer) + { + // Update the program info but retain the status + existingTimer.ChannelId = updatedTimer.ChannelId; + existingTimer.CommunityRating = updatedTimer.CommunityRating; + existingTimer.EndDate = updatedTimer.EndDate; + existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber; + existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle; + existingTimer.Genres = updatedTimer.Genres; + existingTimer.IsMovie = updatedTimer.IsMovie; + existingTimer.IsSeries = updatedTimer.IsSeries; + existingTimer.Tags = updatedTimer.Tags; + existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries; + existingTimer.IsRepeat = updatedTimer.IsRepeat; + existingTimer.Name = updatedTimer.Name; + existingTimer.OfficialRating = updatedTimer.OfficialRating; + existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate; + existingTimer.Overview = updatedTimer.Overview; + existingTimer.ProductionYear = updatedTimer.ProductionYear; + existingTimer.ProgramId = updatedTimer.ProgramId; + existingTimer.SeasonNumber = updatedTimer.SeasonNumber; + existingTimer.StartDate = updatedTimer.StartDate; + existingTimer.ShowId = updatedTimer.ShowId; + existingTimer.ProviderIds = updatedTimer.ProviderIds; + existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds; + } + + public string GetActiveRecordingPath(string id) + { + if (_activeRecordings.TryGetValue(id, out var info)) + { + return info.Path; + } + + return null; + } + + public ActiveRecordingInfo GetActiveRecordingInfo(string path) + { + if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty) + { + return null; + } + + foreach (var (_, recordingInfo) in _activeRecordings) + { + if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) && !recordingInfo.CancellationTokenSource.IsCancellationRequested) + { + var timer = recordingInfo.Timer; + if (timer.Status != RecordingStatus.InProgress) + { + return null; + } + + return recordingInfo; + } + } + + return null; + } + + public Task> GetTimersAsync(CancellationToken cancellationToken) + { + var excludeStatues = new List + { + RecordingStatus.Completed + }; + + var timers = _timerProvider.GetAll() + .Where(i => !excludeStatues.Contains(i.Status)); + + return Task.FromResult(timers); + } + + public Task GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) + { + var config = GetConfiguration(); + + var defaults = new SeriesTimerInfo() + { + PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0), + PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0), + RecordAnyChannel = false, + RecordAnyTime = true, + RecordNewOnly = true, + + Days = new List + { + DayOfWeek.Sunday, + DayOfWeek.Monday, + DayOfWeek.Tuesday, + DayOfWeek.Wednesday, + DayOfWeek.Thursday, + DayOfWeek.Friday, + DayOfWeek.Saturday + } + }; + + if (program is not null) + { + defaults.SeriesId = program.SeriesId; + defaults.ProgramId = program.Id; + defaults.RecordNewOnly = !program.IsRepeat; + defaults.Name = program.Name; + } + + defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly; + defaults.KeepUntil = KeepUntil.UntilDeleted; + + return Task.FromResult(defaults); + } + + public Task> GetSeriesTimersAsync(CancellationToken cancellationToken) + { + return Task.FromResult((IEnumerable)_seriesTimerProvider.GetAll()); + } + + private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId) + { + if (info.EnableAllTuners) + { + return true; + } + + if (string.IsNullOrWhiteSpace(tunerHostId)) + { + throw new ArgumentNullException(nameof(tunerHostId)); + } + + return info.EnabledTuners.Contains(tunerHostId, StringComparison.OrdinalIgnoreCase); + } + + public async Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false); + var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase)); + + foreach (var provider in GetListingProviders()) + { + if (!IsListingProviderEnabledForTuner(provider.Item2, channel.TunerHostId)) + { + _logger.LogDebug("Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); + continue; + } + + _logger.LogDebug("Getting programs for channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); + + var epgChannel = await GetEpgChannelFromTunerChannel(provider.Item1, provider.Item2, channel, cancellationToken).ConfigureAwait(false); + + if (epgChannel is null) + { + _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); + continue; + } + + List 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(); + } + + private List> GetListingProviders() + { + return GetConfiguration().ListingProviders + .Select(i => + { + var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + + return provider is null ? null : new Tuple(provider, i); + }) + .Where(i => i is not null) + .ToList(); + } + + public Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public async Task GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List currentLiveStreams, CancellationToken cancellationToken) + { + _logger.LogInformation("Streaming Channel {Id}", channelId); + + var result = string.IsNullOrEmpty(streamId) ? + null : + currentLiveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase)); + + if (result is not null && result.EnableStreamSharing) + { + result.ConsumerCount++; + + _logger.LogInformation("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount); + + return result; + } + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + result = await hostInstance.GetChannelStream(channelId, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false); + + var openedMediaSource = result.MediaSource; + + result.OriginalStreamId = streamId; + + _logger.LogInformation("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId); + + return result; + } + catch (FileNotFoundException) + { + } + catch (OperationCanceledException) + { + } + } + + throw new ResourceNotFoundException("Tuner not found."); + } + + public async Task> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(channelId)) + { + throw new ArgumentNullException(nameof(channelId)); + } + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false); + + if (sources.Count > 0) + { + return sources; + } + } + catch (NotImplementedException) + { + } + } + + throw new NotImplementedException(); + } + + public Task CloseLiveStream(string id, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task RecordLiveStream(string id, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task ResetTuner(string id, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async void OnTimerProviderTimerFired(object sender, GenericEventArgs e) + { + var timer = e.Argument; + + _logger.LogInformation("Recording timer fired for {0}.", timer.Name); + + try + { + var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds); + + if (recordingEndDate <= DateTime.UtcNow) + { + _logger.LogWarning("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id); + OnTimerOutOfDate(timer); + return; + } + + var activeRecordingInfo = new ActiveRecordingInfo + { + CancellationTokenSource = new CancellationTokenSource(), + Timer = timer, + Id = timer.Id + }; + + if (!_activeRecordings.ContainsKey(timer.Id)) + { + await RecordStream(timer, recordingEndDate, activeRecordingInfo).ConfigureAwait(false); + } + else + { + _logger.LogInformation("Skipping RecordStream because it's already in progress."); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording stream"); + } + } + + private string GetRecordingPath(TimerInfo timer, RemoteSearchResult metadata, out string seriesPath) + { + var recordPath = RecordingPath; + var config = GetConfiguration(); + seriesPath = null; + + if (timer.IsProgramSeries) + { + var customRecordingPath = config.SeriesRecordingPath; + var allowSubfolder = true; + if (!string.IsNullOrWhiteSpace(customRecordingPath)) + { + allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); + recordPath = customRecordingPath; + } + + if (allowSubfolder && config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Series"); + } + + // trim trailing period from the folder name + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim(); + + if (metadata is not null && metadata.ProductionYear.HasValue) + { + folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // Can't use the year here in the folder name because it is the year of the episode, not the series. + recordPath = Path.Combine(recordPath, folderName); + + seriesPath = recordPath; + + if (timer.SeasonNumber.HasValue) + { + folderName = string.Format( + CultureInfo.InvariantCulture, + "Season {0}", + timer.SeasonNumber.Value); + recordPath = Path.Combine(recordPath, folderName); + } + } + else if (timer.IsMovie) + { + var customRecordingPath = config.MovieRecordingPath; + var allowSubfolder = true; + if (!string.IsNullOrWhiteSpace(customRecordingPath)) + { + allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); + recordPath = customRecordingPath; + } + + if (allowSubfolder && config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Movies"); + } + + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); + if (timer.ProductionYear.HasValue) + { + folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // trim trailing period from the folder name + folderName = folderName.TrimEnd('.').Trim(); + + recordPath = Path.Combine(recordPath, folderName); + } + else if (timer.IsKids) + { + if (config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Kids"); + } + + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); + if (timer.ProductionYear.HasValue) + { + folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // trim trailing period from the folder name + folderName = folderName.TrimEnd('.').Trim(); + + recordPath = Path.Combine(recordPath, folderName); + } + else if (timer.IsSports) + { + if (config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Sports"); + } + + recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); + } + else + { + if (config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Other"); + } + + recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); + } + + var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts"; + + return Path.Combine(recordPath, recordingFileName); + } + + private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo) + { + ArgumentNullException.ThrowIfNull(timer); + + LiveTvProgram programInfo = null; + + if (!string.IsNullOrWhiteSpace(timer.ProgramId)) + { + programInfo = GetProgramInfoFromCache(timer); + } + + if (programInfo is null) + { + _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); + programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); + } + + if (programInfo is not null) + { + CopyProgramInfoToTimerInfo(programInfo, timer); + } + + var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); + var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); + + var channelItem = _liveTvManager.GetLiveTvChannel(timer, this); + + string liveStreamId = null; + RecordingStatus recordingStatus; + try + { + var allMediaSources = await _mediaSourceManager.GetPlaybackMediaSources(channelItem, null, true, false, CancellationToken.None).ConfigureAwait(false); + + var mediaStreamInfo = allMediaSources[0]; + IDirectStreamProvider directStreamProvider = null; + + if (mediaStreamInfo.RequiresOpening) + { + var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal( + new LiveStreamRequest + { + ItemId = channelItem.Id, + OpenToken = mediaStreamInfo.OpenToken + }, + CancellationToken.None).ConfigureAwait(false); + + mediaStreamInfo = liveStreamResponse.Item1.MediaSource; + liveStreamId = mediaStreamInfo.LiveStreamId; + directStreamProvider = liveStreamResponse.Item2; + } + + using var recorder = GetRecorder(mediaStreamInfo); + + recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath); + recordPath = EnsureFileUnique(recordPath, timer.Id); + + _libraryMonitor.ReportFileSystemChangeBeginning(recordPath); + + var duration = recordingEndDate - DateTime.UtcNow; + + _logger.LogInformation("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); + + _logger.LogInformation("Writing file to: {Path}", recordPath); + + Action onStarted = async () => + { + activeRecordingInfo.Path = recordPath; + + _activeRecordings.TryAdd(timer.Id, activeRecordingInfo); + + timer.Status = RecordingStatus.InProgress; + _timerProvider.AddOrUpdate(timer, false); + + await SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false); + + await CreateRecordingFolders().ConfigureAwait(false); + + TriggerRefresh(recordPath); + await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false); + }; + + await recorder.Record(directStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); + + recordingStatus = RecordingStatus.Completed; + _logger.LogInformation("Recording completed: {RecordPath}", recordPath); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Recording stopped: {RecordPath}", recordPath); + recordingStatus = RecordingStatus.Completed; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording to {RecordPath}", recordPath); + recordingStatus = RecordingStatus.Error; + } + + if (!string.IsNullOrWhiteSpace(liveStreamId)) + { + try + { + await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing live stream"); + } + } + + DeleteFileIfEmpty(recordPath); + + TriggerRefresh(recordPath); + _libraryMonitor.ReportFileSystemChangeComplete(recordPath, false); + + _activeRecordings.TryRemove(timer.Id, out _); + + if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) + { + const int RetryIntervalSeconds = 60; + _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds); + + timer.Status = RecordingStatus.New; + timer.PrePaddingSeconds = 0; + timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds); + timer.RetryCount++; + _timerProvider.AddOrUpdate(timer); + } + else if (File.Exists(recordPath)) + { + timer.RecordingPath = recordPath; + timer.Status = RecordingStatus.Completed; + _timerProvider.AddOrUpdate(timer, false); + OnSuccessfulRecording(timer, recordPath); + } + else + { + _timerProvider.Delete(timer); + } + } + + private async Task FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken) + { + if (timer.IsSeries) + { + if (timer.SeriesProviderIds.Count == 0) + { + return null; + } + + var query = new RemoteSearchQuery() + { + SearchInfo = new SeriesInfo + { + ProviderIds = timer.SeriesProviderIds, + Name = timer.Name, + MetadataCountryCode = _config.Configuration.MetadataCountryCode, + MetadataLanguage = _config.Configuration.PreferredMetadataLanguage + } + }; + + var results = await _providerManager.GetRemoteSearchResults(query, cancellationToken).ConfigureAwait(false); + + return results.FirstOrDefault(); + } + + return null; + } + + private void DeleteFileIfEmpty(string path) + { + var file = _fileSystem.GetFileInfo(path); + + if (file.Exists && file.Length == 0) + { + try + { + _fileSystem.DeleteFile(path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path); + } + } + } + + private void TriggerRefresh(string path) + { + _logger.LogInformation("Triggering refresh on {Path}", path); + + var item = GetAffectedBaseItem(Path.GetDirectoryName(path)); + + if (item is not null) + { + _logger.LogInformation("Refreshing recording parent {Path}", item.Path); + + _providerManager.QueueRefresh( + item.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + RefreshPaths = new string[] + { + path, + Path.GetDirectoryName(path), + Path.GetDirectoryName(Path.GetDirectoryName(path)) + } + }, + RefreshPriority.High); + } + } + + private BaseItem GetAffectedBaseItem(string path) + { + BaseItem item = null; + + var parentPath = Path.GetDirectoryName(path); + + while (item is null && !string.IsNullOrEmpty(path)) + { + item = _libraryManager.FindByPath(path, null); + + path = Path.GetDirectoryName(path); + } + + if (item is not null) + { + if (item.GetType() == typeof(Folder) && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase)) + { + var parentItem = item.GetParent(); + if (parentItem is not null && parentItem is not AggregateFolder) + { + item = parentItem; + } + } + } + + return item; + } + + private async Task EnforceKeepUpTo(TimerInfo timer, string seriesPath) + { + if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)) + { + return; + } + + if (string.IsNullOrWhiteSpace(seriesPath)) + { + return; + } + + var seriesTimerId = timer.SeriesTimerId; + var seriesTimer = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); + + if (seriesTimer is null || seriesTimer.KeepUpTo <= 0) + { + return; + } + + if (_disposed) + { + return; + } + + await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false); + + try + { + if (_disposed) + { + return; + } + + var timersToDelete = _timerProvider.GetAll() + .Where(i => i.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(i.RecordingPath)) + .Where(i => string.Equals(i.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(i => i.EndDate) + .Where(i => File.Exists(i.RecordingPath)) + .Skip(seriesTimer.KeepUpTo - 1) + .ToList(); + + DeleteLibraryItemsForTimers(timersToDelete); + + if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries) + { + return; + } + + var episodesToDelete = librarySeries.GetItemList( + new InternalItemsQuery + { + OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, + IsVirtualItem = false, + IsFolder = false, + Recursive = true, + DtoOptions = new DtoOptions(true) + }) + .Where(i => i.IsFileProtocol && File.Exists(i.Path)) + .Skip(seriesTimer.KeepUpTo - 1) + .ToList(); + + foreach (var item in episodesToDelete) + { + try + { + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = true + }, + true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting item"); + } + } + } + finally + { + _recordingDeleteSemaphore.Release(); + } + } + + private void DeleteLibraryItemsForTimers(List 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 = GetConfiguration(); + 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 = GetConfiguration(); + + 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(""", "'", 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.Equals(default) ? new List() : _libraryManager.GetPeople(item); + + var directors = people + .Where(i => i.IsType(PersonKind.Director)) + .Select(i => i.Name) + .ToList(); + + foreach (var person in directors) + { + await writer.WriteElementStringAsync(null, "director", null, person).ConfigureAwait(false); + } + + var writers = people + .Where(i => i.IsType(PersonKind.Writer)) + .Select(i => i.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var person in writers) + { + await writer.WriteElementStringAsync(null, "writer", null, person).ConfigureAwait(false); + } + + foreach (var person in writers) + { + await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false); + } + + var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection); + + if (!string.IsNullOrEmpty(tmdbCollection)) + { + await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false); + } + + var imdb = item.GetProviderId(MetadataProvider.Imdb); + if (!string.IsNullOrEmpty(imdb)) + { + if (!isSeriesEpisode) + { + await writer.WriteElementStringAsync(null, "id", null, imdb).ConfigureAwait(false); + } + + await writer.WriteElementStringAsync(null, "imdbid", null, imdb).ConfigureAwait(false); + + // No need to lock if we have identified the content already + lockData = false; + } + + var tvdb = item.GetProviderId(MetadataProvider.Tvdb); + if (!string.IsNullOrEmpty(tvdb)) + { + await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false); + + // No need to lock if we have identified the content already + lockData = false; + } + + var tmdb = item.GetProviderId(MetadataProvider.Tmdb); + if (!string.IsNullOrEmpty(tmdb)) + { + await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false); + + // No need to lock if we have identified the content already + lockData = false; + } + + if (lockData) + { + await writer.WriteElementStringAsync(null, "lockdata", null, "true").ConfigureAwait(false); + } + + if (item.CriticRating.HasValue) + { + await writer.WriteElementStringAsync(null, "criticrating", null, item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(item.Tagline)) + { + await writer.WriteElementStringAsync(null, "tagline", null, item.Tagline).ConfigureAwait(false); + } + + foreach (var studio in item.Studios) + { + await writer.WriteElementStringAsync(null, "studio", null, studio).ConfigureAwait(false); + } + + await writer.WriteEndElementAsync().ConfigureAwait(false); + await writer.WriteEndDocumentAsync().ConfigureAwait(false); + } + } + } + + private LiveTvProgram GetProgramInfoFromCache(string programId) + { + var query = new InternalItemsQuery + { + ItemIds = new[] { _liveTvManager.GetInternalProgramId(programId) }, + Limit = 1, + DtoOptions = new DtoOptions() + }; + + return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); + } + + private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer) + { + return GetProgramInfoFromCache(timer.ProgramId); + } + + private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc) + { + var query = new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + Limit = 1, + DtoOptions = new DtoOptions(true) + { + EnableImages = false + }, + MinStartDate = startDateUtc.AddMinutes(-3), + MaxStartDate = startDateUtc.AddMinutes(3), + OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) } + }; + + if (!string.IsNullOrWhiteSpace(channelId)) + { + query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, channelId) }; + } + + return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); + } + + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration("livetv"); + } + + private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer) + { + if (timer.IsManual) + { + return false; + } + + if (!seriesTimer.RecordAnyTime + && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks) + { + return true; + } + + if (seriesTimer.RecordNewOnly && timer.IsRepeat) + { + return true; + } + + if (!seriesTimer.RecordAnyChannel + && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer); + } + + private void HandleDuplicateShowIds(List timers) + { + // sort showings by HD channels first, then by startDate, record earliest showing possible + foreach (var timer in timers.OrderByDescending(t => _liveTvManager.GetLiveTvChannel(t, this).IsHD).ThenBy(t => t.StartDate).Skip(1)) + { + timer.Status = RecordingStatus.Cancelled; + _timerProvider.Update(timer); + } + } + + private void SearchForDuplicateShowIds(IEnumerable timers) + { + var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList(); + + foreach (var group in groups) + { + if (string.IsNullOrWhiteSpace(group.Key)) + { + continue; + } + + var groupTimers = group.ToList(); + + if (groupTimers.Count < 2) + { + continue; + } + + // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856 + if (group.Key.EndsWith("0000", StringComparison.Ordinal)) + { + continue; + } + + HandleDuplicateShowIds(groupTimers); + } + } + + private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers) + { + var allTimers = GetTimersForSeries(seriesTimer).ToList(); + + var enabledTimersForSeries = new List(); + foreach (var timer in allTimers) + { + var existingTimer = _timerProvider.GetTimer(timer.Id) + ?? (string.IsNullOrWhiteSpace(timer.ProgramId) + ? null + : _timerProvider.GetTimerByProgramId(timer.ProgramId)); + + if (existingTimer is null) + { + if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) + { + timer.Status = RecordingStatus.Cancelled; + } + else + { + enabledTimersForSeries.Add(timer); + } + + _timerProvider.Add(timer); + + TimerCreated?.Invoke(this, new GenericEventArgs(timer)); + } + + // Only update if not currently active - test both new timer and existing in case Id's are different + // Id's could be different if the timer was created manually prior to series timer creation + else if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _)) + { + UpdateExistingTimerWithNewMetadata(existingTimer, timer); + + // Needed by ShouldCancelTimerForSeriesTimer + timer.IsManual = existingTimer.IsManual; + + if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) + { + existingTimer.Status = RecordingStatus.Cancelled; + } + else if (!existingTimer.IsManual) + { + existingTimer.Status = RecordingStatus.New; + } + + if (existingTimer.Status != RecordingStatus.Cancelled) + { + enabledTimersForSeries.Add(existingTimer); + } + + if (updateTimerSettings) + { + existingTimer.KeepUntil = seriesTimer.KeepUntil; + existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; + existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; + existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; + existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; + existingTimer.Priority = seriesTimer.Priority; + existingTimer.SeriesTimerId = seriesTimer.Id; + } + + existingTimer.SeriesTimerId = seriesTimer.Id; + _timerProvider.Update(existingTimer); + } + } + + SearchForDuplicateShowIds(enabledTimersForSeries); + + if (deleteInvalidTimers) + { + var allTimerIds = allTimers + .Select(i => i.Id) + .ToList(); + + var deleteStatuses = new[] + { + RecordingStatus.New + }; + + var deletes = _timerProvider.GetAll() + .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) + .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) + .Where(i => deleteStatuses.Contains(i.Status)) + .ToList(); + + foreach (var timer in deletes) + { + CancelTimerInternal(timer.Id, false, false); + } + } + } + + private IEnumerable GetTimersForSeries(SeriesTimerInfo seriesTimer) + { + ArgumentNullException.ThrowIfNull(seriesTimer); + + var query = new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + ExternalSeriesId = seriesTimer.SeriesId, + DtoOptions = new DtoOptions(true) + { + EnableImages = false + }, + MinEndDate = DateTime.UtcNow + }; + + if (string.IsNullOrEmpty(seriesTimer.SeriesId)) + { + query.Name = seriesTimer.Name; + } + + if (!seriesTimer.RecordAnyChannel) + { + query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, seriesTimer.ChannelId) }; + } + + var tempChannelCache = new Dictionary(); + + return _libraryManager.GetItemList(query).Cast().Select(i => CreateTimer(i, seriesTimer, tempChannelCache)); + } + + private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary tempChannelCache) + { + string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId; + + if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.Equals(default)) + { + if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel)) + { + channel = _libraryManager.GetItemList( + new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, + ItemIds = new[] { parent.ChannelId }, + DtoOptions = new DtoOptions() + }).FirstOrDefault() as LiveTvChannel; + + if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) + { + tempChannelCache[parent.ChannelId] = channel; + } + } + + if (channel is not null || tempChannelCache.TryGetValue(parent.ChannelId, out channel)) + { + channelId = channel.ExternalId; + } + } + + var timer = new TimerInfo + { + ChannelId = channelId, + Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture), + StartDate = parent.StartDate, + EndDate = parent.EndDate.Value, + ProgramId = parent.ExternalId, + PrePaddingSeconds = seriesTimer.PrePaddingSeconds, + PostPaddingSeconds = seriesTimer.PostPaddingSeconds, + IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired, + IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired, + KeepUntil = seriesTimer.KeepUntil, + Priority = seriesTimer.Priority, + Name = parent.Name, + Overview = parent.Overview, + SeriesId = parent.ExternalSeriesId, + SeriesTimerId = seriesTimer.Id, + ShowId = parent.ShowId + }; + + CopyProgramInfoToTimerInfo(parent, timer, tempChannelCache); + + return timer; + } + + private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo) + { + var tempChannelCache = new Dictionary(); + CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache); + } + + private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary tempChannelCache) + { + string channelId = null; + + if (!programInfo.ChannelId.Equals(default)) + { + if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel)) + { + channel = _libraryManager.GetItemList( + new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, + ItemIds = new[] { programInfo.ChannelId }, + DtoOptions = new DtoOptions() + }).FirstOrDefault() as LiveTvChannel; + + if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) + { + tempChannelCache[programInfo.ChannelId] = channel; + } + } + + if (channel is not null || tempChannelCache.TryGetValue(programInfo.ChannelId, out channel)) + { + channelId = channel.ExternalId; + } + } + + timerInfo.Name = programInfo.Name; + timerInfo.StartDate = programInfo.StartDate; + timerInfo.EndDate = programInfo.EndDate.Value; + + if (!string.IsNullOrWhiteSpace(channelId)) + { + timerInfo.ChannelId = channelId; + } + + timerInfo.SeasonNumber = programInfo.ParentIndexNumber; + timerInfo.EpisodeNumber = programInfo.IndexNumber; + timerInfo.IsMovie = programInfo.IsMovie; + timerInfo.ProductionYear = programInfo.ProductionYear; + timerInfo.EpisodeTitle = programInfo.EpisodeTitle; + timerInfo.OriginalAirDate = programInfo.PremiereDate; + timerInfo.IsProgramSeries = programInfo.IsSeries; + + timerInfo.IsSeries = programInfo.IsSeries; + + timerInfo.CommunityRating = programInfo.CommunityRating; + timerInfo.Overview = programInfo.Overview; + timerInfo.OfficialRating = programInfo.OfficialRating; + timerInfo.IsRepeat = programInfo.IsRepeat; + timerInfo.SeriesId = programInfo.ExternalSeriesId; + timerInfo.ProviderIds = programInfo.ProviderIds; + timerInfo.Tags = programInfo.Tags; + + var seriesProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var providerId in timerInfo.ProviderIds) + { + const string Search = "Series"; + if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase)) + { + seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value; + } + } + + timerInfo.SeriesProviderIds = seriesProviderIds; + } + + private bool IsProgramAlreadyInLibrary(TimerInfo program) + { + if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle)) + { + var seriesIds = _libraryManager.GetItemIds( + new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + Name = program.Name + }).ToArray(); + + if (seriesIds.Length == 0) + { + return false; + } + + if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) + { + var result = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Episode }, + ParentIndexNumber = program.SeasonNumber.Value, + IndexNumber = program.EpisodeNumber.Value, + AncestorIds = seriesIds, + IsVirtualItem = false, + Limit = 1 + }); + + if (result.Count > 0) + { + return true; + } + } + } + + return false; + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _recordingDeleteSemaphore.Dispose(); + + foreach (var pair in _activeRecordings.ToList()) + { + pair.Value.CancellationTokenSource.Cancel(); + } + + _disposed = true; + } + + public IEnumerable GetRecordingFolders() + { + var defaultFolder = RecordingPath; + var defaultName = "Recordings"; + + if (Directory.Exists(defaultFolder)) + { + yield return new VirtualFolderInfo + { + Locations = new string[] { defaultFolder }, + Name = defaultName + }; + } + + var customPath = GetConfiguration().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 = GetConfiguration().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 + }; + } + } + + public async Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) + { + var list = new List(); + + var configuredDeviceIds = GetConfiguration().TunerHosts + .Where(i => !string.IsNullOrWhiteSpace(i.DeviceId)) + .Select(i => i.DeviceId) + .ToList(); + + foreach (var host in _liveTvManager.TunerHosts) + { + var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); + + if (newDevicesOnly) + { + discoveredDevices = discoveredDevices.Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + list.AddRange(discoveredDevices); + } + + return list; + } + + public async Task ScanForTunerDeviceChanges(CancellationToken cancellationToken) + { + foreach (var host in _liveTvManager.TunerHosts) + { + await ScanForTunerDeviceChanges(host, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ScanForTunerDeviceChanges(ITunerHost host, CancellationToken cancellationToken) + { + var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); + + var configuredDevices = GetConfiguration().TunerHosts + .Where(i => string.Equals(i.Type, host.Type, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var device in discoveredDevices) + { + var configuredDevice = configuredDevices.FirstOrDefault(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase)); + + if (configuredDevice is not null && !string.Equals(device.Url, configuredDevice.Url, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Tuner url has changed from {PreviousUrl} to {NewUrl}", configuredDevice.Url, device.Url); + + configuredDevice.Url = device.Url; + await _liveTvManager.SaveTunerHost(configuredDevice).ConfigureAwait(false); + } + } + } + + private async Task> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) + { + try + { + var discoveredDevices = await host.DiscoverDevices(discoveryDurationMs, cancellationToken).ConfigureAwait(false); + + foreach (var device in discoveredDevices) + { + _logger.LogInformation("Discovered tuner device {0} at {1}", host.Name, device.Url); + } + + return discoveredDevices; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error discovering tuner devices"); + + return new List(); + } + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs b/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs new file mode 100644 index 0000000000..132a5fc516 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs @@ -0,0 +1,362 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public class EncodedRecorder : IRecorder + { + private readonly ILogger _logger; + private readonly IMediaEncoder _mediaEncoder; + private readonly IServerApplicationPaths _appPaths; + private readonly TaskCompletionSource _taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private bool _hasExited; + private FileStream _logFileStream; + private string _targetPath; + private Process _process; + private bool _disposed; + + public EncodedRecorder( + ILogger logger, + IMediaEncoder mediaEncoder, + IServerApplicationPaths appPaths, + IServerConfigurationManager serverConfigurationManager) + { + _logger = logger; + _mediaEncoder = mediaEncoder; + _appPaths = appPaths; + _serverConfigurationManager = serverConfigurationManager; + } + + private static bool CopySubtitles => false; + + public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) + { + return Path.ChangeExtension(targetFile, ".ts"); + } + + public async Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + // The media source is infinite so we need to handle stopping ourselves + using var durationToken = new CancellationTokenSource(duration); + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + + await RecordFromFile(mediaSource, mediaSource.Path, targetFile, onStarted, cancellationTokenSource.Token).ConfigureAwait(false); + + _logger.LogInformation("Recording completed to file {Path}", targetFile); + } + + private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, Action onStarted, CancellationToken cancellationToken) + { + _targetPath = targetFile; + Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); + + var processStartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + UseShellExecute = false, + + RedirectStandardError = true, + RedirectStandardInput = true, + + FileName = _mediaEncoder.EncoderPath, + Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile), + + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }; + + _logger.LogInformation("{Filename} {Arguments}", processStartInfo.FileName, processStartInfo.Arguments); + + var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt"); + Directory.CreateDirectory(Path.GetDirectoryName(logFilePath)); + + // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + _logFileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + + await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureAwait(false); + await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + processStartInfo.FileName + " " + processStartInfo.Arguments + Environment.NewLine + Environment.NewLine), cancellationToken).ConfigureAwait(false); + + _process = new Process + { + StartInfo = processStartInfo, + EnableRaisingEvents = true + }; + _process.Exited += (_, _) => OnFfMpegProcessExited(_process); + + _process.Start(); + + cancellationToken.Register(Stop); + + onStarted(); + + // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback + _ = StartStreamingLog(_process.StandardError.BaseStream, _logFileStream); + + _logger.LogInformation("ffmpeg recording process started for {Path}", _targetPath); + + // Block until ffmpeg exits + await _taskCompletionSource.Task.ConfigureAwait(false); + } + + private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile) + { + string videoArgs; + if (EncodeVideo(mediaSource)) + { + const int MaxBitrate = 25000000; + videoArgs = string.Format( + CultureInfo.InvariantCulture, + "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41", + GetOutputSizeParam(), + MaxBitrate); + } + else + { + videoArgs = "-codec:v:0 copy"; + } + + videoArgs += " -fflags +genpts"; + + var flags = new List(); + if (mediaSource.IgnoreDts) + { + flags.Add("+igndts"); + } + + if (mediaSource.IgnoreIndex) + { + flags.Add("+ignidx"); + } + + if (mediaSource.GenPtsInput) + { + flags.Add("+genpts"); + } + + var inputModifier = "-async 1 -vsync -1"; + + if (flags.Count > 0) + { + inputModifier += " -fflags " + string.Join(string.Empty, flags); + } + + if (mediaSource.ReadAtNativeFramerate) + { + inputModifier += " -re"; + } + + if (mediaSource.RequiresLooping) + { + inputModifier += " -stream_loop -1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2"; + } + + var analyzeDurationSeconds = 5; + var analyzeDuration = " -analyzeduration " + + (analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture); + inputModifier += analyzeDuration; + + var subtitleArgs = CopySubtitles ? " -codec:s copy" : " -sn"; + + // var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase) ? + // " -f mp4 -movflags frag_keyframe+empty_moov" : + // string.Empty; + + var outputParam = string.Empty; + + var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null); + var commandLineArgs = string.Format( + CultureInfo.InvariantCulture, + "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", + inputTempFile, + targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename + videoArgs, + GetAudioArgs(mediaSource), + subtitleArgs, + outputParam, + threads); + + return inputModifier + " " + commandLineArgs; + } + + private static string GetAudioArgs(MediaSourceInfo mediaSource) + { + return "-codec:a:0 copy"; + + // var audioChannels = 2; + // var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); + // if (audioStream is not null) + // { + // audioChannels = audioStream.Channels ?? audioChannels; + // } + // return "-codec:a:0 aac -strict experimental -ab 320000"; + } + + private static bool EncodeVideo(MediaSourceInfo mediaSource) + { + return false; + } + + protected string GetOutputSizeParam() + => "-vf \"yadif=0:-1:0\""; + + private void Stop() + { + if (!_hasExited) + { + try + { + _logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath); + + _process.StandardInput.WriteLine("q"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath); + } + + if (_hasExited) + { + return; + } + + try + { + _logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath); + + if (_process.WaitForExit(10000)) + { + return; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath); + } + + if (_hasExited) + { + return; + } + + try + { + _logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath); + + _process.Kill(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath); + } + } + } + + /// + /// Processes the exited. + /// + private void OnFfMpegProcessExited(Process process) + { + using (process) + { + _hasExited = true; + + _logFileStream?.Dispose(); + _logFileStream = null; + + var exitCode = process.ExitCode; + + _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath); + + if (exitCode == 0) + { + _taskCompletionSource.TrySetResult(true); + } + else + { + _taskCompletionSource.TrySetException( + new FfmpegException( + string.Format( + CultureInfo.InvariantCulture, + "Recording for {0} failed. Exit code {1}", + _targetPath, + exitCode))); + } + } + } + + private async Task StartStreamingLog(Stream source, FileStream target) + { + try + { + using (var reader = new StreamReader(source)) + { + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) + { + var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); + + await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false); + await target.FlushAsync().ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading ffmpeg recording log"); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and optionally managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _logFileStream?.Dispose(); + _process?.Dispose(); + } + + _logFileStream = null; + _process = null; + + _disposed = true; + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs b/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs new file mode 100644 index 0000000000..e750c05ac8 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs @@ -0,0 +1,21 @@ +#pragma warning disable CS1591 + +using System.Threading.Tasks; +using MediaBrowser.Controller.Plugins; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public sealed class EntryPoint : IServerEntryPoint + { + /// + public Task RunAsync() + { + return EmbyTV.Current.Start(); + } + + /// + public void Dispose() + { + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs b/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs new file mode 100644 index 0000000000..43d308c434 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs @@ -0,0 +1,54 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.LiveTv; + +namespace Jellyfin.LiveTv.EmbyTV +{ + internal class EpgChannelData + { + private readonly Dictionary _channelsById; + + private readonly Dictionary _channelsByNumber; + + private readonly Dictionary _channelsByName; + + public EpgChannelData(IEnumerable channels) + { + _channelsById = new Dictionary(StringComparer.OrdinalIgnoreCase); + _channelsByNumber = new Dictionary(StringComparer.OrdinalIgnoreCase); + _channelsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var channel in channels) + { + _channelsById[channel.Id] = channel; + + if (!string.IsNullOrEmpty(channel.Number)) + { + _channelsByNumber[channel.Number] = channel; + } + + var normalizedName = NormalizeName(channel.Name ?? string.Empty); + if (!string.IsNullOrWhiteSpace(normalizedName)) + { + _channelsByName[normalizedName] = channel; + } + } + } + + public ChannelInfo? GetChannelById(string id) + => _channelsById.GetValueOrDefault(id); + + public ChannelInfo? GetChannelByNumber(string number) + => _channelsByNumber.GetValueOrDefault(number); + + public ChannelInfo? GetChannelByName(string name) + => _channelsByName.GetValueOrDefault(name); + + public static string NormalizeName(string value) + { + return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs b/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs new file mode 100644 index 0000000000..7ed42e2634 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs @@ -0,0 +1,27 @@ +#pragma warning disable CS1591 + +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public interface IRecorder : IDisposable + { + /// + /// Records the specified media source. + /// + /// The direct stream provider, or null. + /// The media source. + /// The target file. + /// The duration to record. + /// An action to perform when recording starts. + /// The cancellation token. + /// A that represents the recording operation. + Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken); + + string GetOutputPath(MediaSourceInfo mediaSource, string targetFile); + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs b/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs new file mode 100644 index 0000000000..547ffeb668 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs @@ -0,0 +1,163 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.Json; +using Jellyfin.Extensions.Json; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public class ItemDataProvider + where T : class + { + private readonly string _dataPath; + private readonly object _fileDataLock = new object(); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private T[]? _items; + + public ItemDataProvider( + ILogger logger, + string dataPath, + Func equalityComparer) + { + Logger = logger; + _dataPath = dataPath; + EqualityComparer = equalityComparer; + } + + protected ILogger Logger { get; } + + protected Func EqualityComparer { get; } + + [MemberNotNull(nameof(_items))] + private void EnsureLoaded() + { + if (_items is not null) + { + return; + } + + if (File.Exists(_dataPath)) + { + Logger.LogInformation("Loading live tv data from {Path}", _dataPath); + + try + { + var bytes = File.ReadAllBytes(_dataPath); + _items = JsonSerializer.Deserialize(bytes, _jsonOptions); + if (_items is null) + { + Logger.LogError("Error deserializing {Path}, data was null", _dataPath); + _items = Array.Empty(); + } + + return; + } + catch (JsonException ex) + { + Logger.LogError(ex, "Error deserializing {Path}", _dataPath); + } + } + + _items = Array.Empty(); + } + + private void SaveList() + { + Directory.CreateDirectory(Path.GetDirectoryName(_dataPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(_dataPath))); + var jsonString = JsonSerializer.Serialize(_items, _jsonOptions); + File.WriteAllText(_dataPath, jsonString); + } + + public IReadOnlyList GetAll() + { + lock (_fileDataLock) + { + EnsureLoaded(); + return (T[])_items.Clone(); + } + } + + public virtual void Update(T item) + { + ArgumentNullException.ThrowIfNull(item); + + lock (_fileDataLock) + { + EnsureLoaded(); + + var index = Array.FindIndex(_items, i => EqualityComparer(i, item)); + if (index == -1) + { + throw new ArgumentException("item not found"); + } + + _items[index] = item; + + SaveList(); + } + } + + public virtual void Add(T item) + { + ArgumentNullException.ThrowIfNull(item); + + lock (_fileDataLock) + { + EnsureLoaded(); + + if (_items.Any(i => EqualityComparer(i, item))) + { + throw new ArgumentException("item already exists", nameof(item)); + } + + int oldLen = _items.Length; + var newList = new T[oldLen + 1]; + _items.CopyTo(newList, 0); + newList[oldLen] = item; + _items = newList; + + SaveList(); + } + } + + public virtual void AddOrUpdate(T item) + { + lock (_fileDataLock) + { + EnsureLoaded(); + + int index = Array.FindIndex(_items, i => EqualityComparer(i, item)); + if (index == -1) + { + int oldLen = _items.Length; + var newList = new T[oldLen + 1]; + _items.CopyTo(newList, 0); + newList[oldLen] = item; + _items = newList; + } + else + { + _items[index] = item; + } + + SaveList(); + } + } + + public virtual void Delete(T item) + { + lock (_fileDataLock) + { + EnsureLoaded(); + _items = _items.Where(i => !EqualityComparer(i, item)).ToArray(); + + SaveList(); + } + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs b/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs new file mode 100644 index 0000000000..e8570f0e0d --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs @@ -0,0 +1,19 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; + +namespace Jellyfin.LiveTv.EmbyTV +{ + /// + /// Class containing extension methods for working with the nfo configuration. + /// + public static class NfoConfigurationExtensions + { + /// + /// Gets the nfo configuration. + /// + /// The configuration manager. + /// The nfo configuration. + public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager) + => configurationManager.GetConfiguration("xbmcmetadata"); + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs b/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs new file mode 100644 index 0000000000..6bda231b24 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs @@ -0,0 +1,83 @@ +#pragma warning disable CS1591 + +using System; +using System.Globalization; +using System.Text; +using MediaBrowser.Controller.LiveTv; + +namespace Jellyfin.LiveTv.EmbyTV +{ + internal static class RecordingHelper + { + public static DateTime GetStartTime(TimerInfo timer) + { + return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds); + } + + public static string GetRecordingName(TimerInfo info) + { + var name = info.Name; + + if (info.IsProgramSeries) + { + var addHyphen = true; + + if (info.SeasonNumber.HasValue && info.EpisodeNumber.HasValue) + { + name += string.Format( + CultureInfo.InvariantCulture, + " S{0}E{1}", + info.SeasonNumber.Value.ToString("00", CultureInfo.InvariantCulture), + info.EpisodeNumber.Value.ToString("00", CultureInfo.InvariantCulture)); + addHyphen = false; + } + else if (info.OriginalAirDate.HasValue) + { + if (info.OriginalAirDate.Value.Date.Equals(info.StartDate.Date)) + { + name += " " + GetDateString(info.StartDate); + } + else + { + name += " " + info.OriginalAirDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + } + else + { + name += " " + GetDateString(info.StartDate); + } + + if (!string.IsNullOrWhiteSpace(info.EpisodeTitle)) + { + var tmpName = name; + if (addHyphen) + { + tmpName += " -"; + } + + tmpName += " " + info.EpisodeTitle; + // Since the filename will be used with file ext. (.mp4, .ts, etc) + if (Encoding.UTF8.GetByteCount(tmpName) < 250) + { + name = tmpName; + } + } + } + else if (info.IsMovie && info.ProductionYear is not null) + { + name += " (" + info.ProductionYear + ")"; + } + else + { + name += " " + GetDateString(info.StartDate); + } + + return name; + } + + private static string GetDateString(DateTime date) + { + return date.ToLocalTime().ToString("yyyy_MM_dd_HH_mm_ss", CultureInfo.InvariantCulture); + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs b/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs new file mode 100644 index 0000000000..2ebe60b296 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs @@ -0,0 +1,24 @@ +#pragma warning disable CS1591 + +using System; +using MediaBrowser.Controller.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public class SeriesTimerManager : ItemDataProvider + { + public SeriesTimerManager(ILogger logger, string dataPath) + : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + { + } + + /// + 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/EmbyTV/TimerManager.cs new file mode 100644 index 0000000000..37b1fa14cc --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs @@ -0,0 +1,181 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.Linq; +using System.Threading; +using Jellyfin.Data.Events; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public class TimerManager : ItemDataProvider + { + private readonly ConcurrentDictionary _timers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + public TimerManager(ILogger logger, string dataPath) + : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + { + } + + public event EventHandler>? TimerFired; + + public void RestartTimers() + { + StopTimers(); + + foreach (var item in GetAll()) + { + AddOrUpdateSystemTimer(item); + } + } + + public void StopTimers() + { + foreach (var pair in _timers.ToList()) + { + pair.Value.Dispose(); + } + + _timers.Clear(); + } + + public override void Delete(TimerInfo item) + { + base.Delete(item); + StopTimer(item); + } + + public override void Update(TimerInfo item) + { + base.Update(item); + AddOrUpdateSystemTimer(item); + } + + public void AddOrUpdate(TimerInfo item, bool resetTimer) + { + if (resetTimer) + { + AddOrUpdate(item); + return; + } + + base.AddOrUpdate(item); + } + + public override void AddOrUpdate(TimerInfo item) + { + base.AddOrUpdate(item); + AddOrUpdateSystemTimer(item); + } + + public override void Add(TimerInfo item) + { + ArgumentException.ThrowIfNullOrEmpty(item.Id); + + base.Add(item); + 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)) + { + return; + } + + var startDate = RecordingHelper.GetStartTime(item); + var now = DateTime.UtcNow; + + if (startDate < now) + { + TimerFired?.Invoke(this, new GenericEventArgs(item)); + return; + } + + var dueTime = startDate - now; + StartTimer(item, dueTime); + } + + private void StartTimer(TimerInfo item, TimeSpan dueTime) + { + var timer = new Timer(TimerCallback, item.Id, dueTime, TimeSpan.Zero); + + if (_timers.TryAdd(item.Id, timer)) + { + if (item.IsSeries) + { + Logger.LogInformation( + "Creating recording timer for {Id}, {Name} {SeasonNumber}x{EpisodeNumber:D2} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", + item.Id, + item.Name, + item.SeasonNumber, + item.EpisodeNumber, + item.ChannelId, + dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), + item.StartDate); + } + else + { + Logger.LogInformation( + "Creating recording timer for {Id}, {Name} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", + item.Id, + item.Name, + item.ChannelId, + dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), + item.StartDate); + } + } + else + { + timer.Dispose(); + Logger.LogWarning("Timer already exists for item {Id}", item.Id); + } + } + + private void StopTimer(TimerInfo item) + { + if (_timers.TryRemove(item.Id, out var timer)) + { + timer.Dispose(); + } + } + + private void TimerCallback(object? state) + { + var timerId = (string?)state ?? throw new ArgumentNullException(nameof(state)); + + var timer = GetAll().FirstOrDefault(i => string.Equals(i.Id, timerId, StringComparison.OrdinalIgnoreCase)); + if (timer is not null) + { + TimerFired?.Invoke(this, new GenericEventArgs(timer)); + } + } + + public TimerInfo? GetTimer(string id) + { + return 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)); + } + } +} diff --git a/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs b/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs new file mode 100644 index 0000000000..9d442e20cc --- /dev/null +++ b/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs @@ -0,0 +1,61 @@ +#nullable disable + +#pragma warning disable CA1711 +#pragma warning disable CS1591 + +using System; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.LiveTv +{ + public sealed class ExclusiveLiveStream : ILiveStream + { + private readonly Func _closeFn; + + public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func closeFn) + { + MediaSource = mediaSource; + EnableStreamSharing = false; + _closeFn = closeFn; + ConsumerCount = 1; + UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + } + + public int ConsumerCount { get; set; } + + public string OriginalStreamId { get; set; } + + public string TunerHostId => null; + + public bool EnableStreamSharing { get; set; } + + public MediaSourceInfo MediaSource { get; set; } + + public string UniqueId { get; } + + public Task Close() + { + return _closeFn(); + } + + public Stream GetStream() + { + throw new NotSupportedException(); + } + + public Task Open(CancellationToken openCancellationToken) + { + return Task.CompletedTask; + } + + /// + public void Dispose() + { + } + } +} diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj new file mode 100644 index 0000000000..3910064499 --- /dev/null +++ b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj @@ -0,0 +1,22 @@ + + + net8.0 + true + + + + + <_Parameter1>Jellyfin.LiveTv.Tests + + + + + + + + + + + + + diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs new file mode 100644 index 0000000000..3b20cd160b --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -0,0 +1,810 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Net.Mime; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; +using Jellyfin.LiveTv.Listings.SchedulesDirectDtos; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Listings +{ + public class SchedulesDirect : IListingsProvider, IDisposable + { + private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); + + private readonly ConcurrentDictionary _tokens = new ConcurrentDictionary(); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private DateTime _lastErrorResponse; + private bool _disposed = false; + + public SchedulesDirect( + ILogger logger, + IHttpClientFactory httpClientFactory) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + /// + public string Name => "Schedules Direct"; + + /// + public string Type => nameof(SchedulesDirect); + + private static List GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc) + { + var dates = new List(); + + var start = new[] { startDateUtc, startDateUtc.ToLocalTime() }.Min().Date; + var end = new[] { endDateUtc, endDateUtc.ToLocalTime() }.Max().Date; + + while (start <= end) + { + dates.Add(start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + start = start.AddDays(1); + } + + return dates; + } + + public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(channelId); + + // Normalize incoming input + channelId = channelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); + + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(token)) + { + _logger.LogWarning("SchedulesDirect token is empty, returning empty program list"); + + return Enumerable.Empty(); + } + + var dates = GetScheduleRequestDates(startDateUtc, endDateUtc); + + _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId); + var requestList = new List() + { + new RequestScheduleForChannelDto() + { + StationId = channelId, + Date = dates + } + }; + + _logger.LogDebug("Request string for schedules is: {@RequestString}", requestList); + + using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules"); + options.Content = JsonContent.Create(requestList, options: _jsonOptions); + options.Headers.TryAddWithoutValidation("token", token); + using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); + var dailySchedules = await response.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (dailySchedules is null) + { + return Array.Empty(); + } + + _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); + + using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs"); + programRequestOptions.Headers.TryAddWithoutValidation("token", token); + + var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); + programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions); + + using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); + var programDetails = await innerResponse.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (programDetails is null) + { + return Array.Empty(); + } + + var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y); + + var programIdsWithImages = programDetails + .Where(p => p.HasImageArtwork).Select(p => p.ProgramId) + .ToList(); + + var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); + + var programsInfo = new List(); + foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs)) + { + // _logger.LogDebug("Proccesing Schedule for statio ID " + stationID + + // " which corresponds to channel " + channelNumber + " and program id " + + // schedule.ProgramId + " which says it has images? " + + // programDict[schedule.ProgramId].hasImageArtwork); + + if (string.IsNullOrEmpty(schedule.ProgramId)) + { + continue; + } + + if (images is not null) + { + var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]); + if (imageIndex > -1) + { + var programEntry = programDict[schedule.ProgramId]; + + var allImages = images[imageIndex].Data; + var imagesWithText = allImages.Where(i => string.Equals(i.Text, "yes", StringComparison.OrdinalIgnoreCase)).ToList(); + var imagesWithoutText = allImages.Where(i => string.Equals(i.Text, "no", StringComparison.OrdinalIgnoreCase)).ToList(); + + const double DesiredAspect = 2.0 / 3; + + programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, DesiredAspect, token) ?? + GetProgramImage(ApiUrl, allImages, DesiredAspect, token); + + const double WideAspect = 16.0 / 9; + + programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, WideAspect, token); + + // Don't supply the same image twice + if (string.Equals(programEntry.PrimaryImage, programEntry.ThumbImage, StringComparison.Ordinal)) + { + programEntry.ThumbImage = null; + } + + programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, WideAspect, token); + + // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-LO", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-LOT", false); + } + } + + programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.ProgramId])); + } + + return programsInfo; + } + + private static int GetSizeOrder(ImageDataDto image) + { + if (int.TryParse(image.Height, out int value)) + { + return value; + } + + return 0; + } + + private static string GetChannelNumber(MapDto map) + { + var channelNumber = map.LogicalChannelNumber; + + if (string.IsNullOrWhiteSpace(channelNumber)) + { + channelNumber = map.Channel; + } + + if (string.IsNullOrWhiteSpace(channelNumber)) + { + channelNumber = map.AtscMajor + "." + map.AtscMinor; + } + + return channelNumber.TrimStart('0'); + } + + private static bool IsMovie(ProgramDetailsDto programInfo) + { + return string.Equals(programInfo.EntityType, "movie", StringComparison.OrdinalIgnoreCase); + } + + private ProgramInfo GetProgram(string channelId, ProgramDto programInfo, ProgramDetailsDto details) + { + if (programInfo.AirDateTime is null) + { + return null; + } + + var startAt = programInfo.AirDateTime.Value; + var endAt = startAt.AddSeconds(programInfo.Duration); + var audioType = ProgramAudio.Stereo; + + var programId = programInfo.ProgramId ?? string.Empty; + + string newID = programId + "T" + startAt.Ticks + "C" + channelId; + + if (programInfo.AudioProperties.Count != 0) + { + if (programInfo.AudioProperties.Contains("atmos", StringComparison.OrdinalIgnoreCase)) + { + audioType = ProgramAudio.Atmos; + } + else if (programInfo.AudioProperties.Contains("dd 5.1", StringComparison.OrdinalIgnoreCase)) + { + audioType = ProgramAudio.DolbyDigital; + } + else if (programInfo.AudioProperties.Contains("dd", StringComparison.OrdinalIgnoreCase)) + { + audioType = ProgramAudio.DolbyDigital; + } + else if (programInfo.AudioProperties.Contains("stereo", StringComparison.OrdinalIgnoreCase)) + { + audioType = ProgramAudio.Stereo; + } + else + { + audioType = ProgramAudio.Mono; + } + } + + string episodeTitle = null; + if (details.EpisodeTitle150 is not null) + { + episodeTitle = details.EpisodeTitle150; + } + + var info = new ProgramInfo + { + ChannelId = channelId, + Id = newID, + StartDate = startAt, + EndDate = endAt, + Name = details.Titles[0].Title120 ?? "Unknown", + OfficialRating = null, + CommunityRating = null, + EpisodeTitle = episodeTitle, + Audio = audioType, + // IsNew = programInfo.@new ?? false, + IsRepeat = programInfo.New is null, + IsSeries = string.Equals(details.EntityType, "episode", StringComparison.OrdinalIgnoreCase), + ImageUrl = details.PrimaryImage, + ThumbImageUrl = details.ThumbImage, + IsKids = string.Equals(details.Audience, "children", StringComparison.OrdinalIgnoreCase), + IsSports = string.Equals(details.EntityType, "sports", StringComparison.OrdinalIgnoreCase), + IsMovie = IsMovie(details), + Etag = programInfo.Md5, + IsLive = string.Equals(programInfo.LiveTapeDelay, "live", StringComparison.OrdinalIgnoreCase), + IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).Contains("premiere", StringComparison.OrdinalIgnoreCase) + }; + + var showId = programId; + + if (!info.IsSeries) + { + // It's also a series if it starts with SH + info.IsSeries = showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) && showId.Length >= 14; + } + + // According to SchedulesDirect, these are generic, unidentified episodes + // SH005316560000 + var hasUniqueShowId = !showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) || + !showId.EndsWith("0000", StringComparison.OrdinalIgnoreCase); + + if (!hasUniqueShowId) + { + showId = newID; + } + + info.ShowId = showId; + + if (programInfo.VideoProperties is not null) + { + info.IsHD = programInfo.VideoProperties.Contains("hdtv", StringComparison.OrdinalIgnoreCase); + info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparison.OrdinalIgnoreCase); + } + + if (details.ContentRating is not null && details.ContentRating.Count > 0) + { + info.OfficialRating = details.ContentRating[0].Code.Replace("TV", "TV-", StringComparison.Ordinal) + .Replace("--", "-", StringComparison.Ordinal); + + var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" }; + if (invalid.Contains(info.OfficialRating, StringComparison.OrdinalIgnoreCase)) + { + info.OfficialRating = null; + } + } + + if (details.Descriptions is not null) + { + if (details.Descriptions.Description1000 is not null && details.Descriptions.Description1000.Count > 0) + { + info.Overview = details.Descriptions.Description1000[0].Description; + } + else if (details.Descriptions.Description100 is not null && details.Descriptions.Description100.Count > 0) + { + info.Overview = details.Descriptions.Description100[0].Description; + } + } + + if (info.IsSeries) + { + info.SeriesId = programId.Substring(0, 10); + + info.SeriesProviderIds[MetadataProvider.Zap2It.ToString()] = info.SeriesId; + + if (details.Metadata is not null) + { + foreach (var metadataProgram in details.Metadata) + { + var gracenote = metadataProgram.Gracenote; + if (gracenote is not null) + { + info.SeasonNumber = gracenote.Season; + + if (gracenote.Episode > 0) + { + info.EpisodeNumber = gracenote.Episode; + } + + break; + } + } + } + } + + if (details.OriginalAirDate is not null) + { + info.OriginalAirDate = details.OriginalAirDate; + info.ProductionYear = info.OriginalAirDate.Value.Year; + } + + if (details.Movie is not null) + { + if (!string.IsNullOrEmpty(details.Movie.Year) + && int.TryParse(details.Movie.Year, out int year)) + { + info.ProductionYear = year; + } + } + + if (details.Genres is not null) + { + info.Genres = details.Genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); + info.IsNews = details.Genres.Contains("news", StringComparison.OrdinalIgnoreCase); + + if (info.Genres.Contains("children", StringComparison.OrdinalIgnoreCase)) + { + info.IsKids = true; + } + } + + return info; + } + + private static string GetProgramImage(string apiUrl, IEnumerable images, double desiredAspect, string token) + { + var match = images + .OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i))) + .ThenByDescending(i => GetSizeOrder(i)) + .FirstOrDefault(); + + if (match is null) + { + return null; + } + + var uri = match.Uri; + + if (string.IsNullOrWhiteSpace(uri)) + { + return null; + } + + if (uri.Contains("http", StringComparison.OrdinalIgnoreCase)) + { + return uri; + } + + return apiUrl + "/image/" + uri + "?token=" + token; + } + + private static double GetAspectRatio(ImageDataDto i) + { + int width = 0; + int height = 0; + + if (!string.IsNullOrWhiteSpace(i.Width)) + { + _ = int.TryParse(i.Width, out width); + } + + if (!string.IsNullOrWhiteSpace(i.Height)) + { + _ = int.TryParse(i.Height, out height); + } + + if (height == 0 || width == 0) + { + return 0; + } + + double result = width; + result /= height; + return result; + } + + private async Task> GetImageForPrograms( + ListingsProviderInfo info, + IReadOnlyList programIds, + CancellationToken cancellationToken) + { + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + if (programIds.Count == 0) + { + return Array.Empty(); + } + + StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); + foreach (var i in programIds) + { + str.Append('"') + .Append(i[..10]) + .Append("\","); + } + + // Remove last , + str.Length--; + str.Append(']'); + + using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs") + { + Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json) + }; + message.Headers.TryAddWithoutValidation("token", token); + + try + { + using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false); + return await innerResponse2.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting image info from schedules direct"); + + return Array.Empty(); + } + } + + public async Task> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken) + { + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + var lineups = new List(); + + if (string.IsNullOrWhiteSpace(token)) + { + return lineups; + } + + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&postalcode=" + location); + options.Headers.TryAddWithoutValidation("token", token); + + try + { + using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); + var root = await httpResponse.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (root is not null) + { + foreach (HeadendsDto headend in root) + { + foreach (LineupDto lineup in headend.Lineups) + { + lineups.Add(new NameIdPair + { + Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name, + Id = lineup.Uri?[18..] + }); + } + } + } + else + { + _logger.LogInformation("No lineups available"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting headends"); + } + + return lineups; + } + + private async Task GetToken(ListingsProviderInfo info, CancellationToken cancellationToken) + { + var username = info.Username; + + // Reset the token if there's no username + if (string.IsNullOrWhiteSpace(username)) + { + return null; + } + + var password = info.Password; + if (string.IsNullOrEmpty(password)) + { + return null; + } + + // Avoid hammering SD + if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1) + { + return null; + } + + if (!_tokens.TryGetValue(username, out NameValuePair savedToken)) + { + savedToken = new NameValuePair(); + _tokens.TryAdd(username, savedToken); + } + + if (!string.IsNullOrEmpty(savedToken.Name) + && long.TryParse(savedToken.Value, CultureInfo.InvariantCulture, out long ticks)) + { + // If it's under 24 hours old we can still use it + if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks) + { + return savedToken.Name; + } + } + + await _tokenSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); + savedToken.Name = result; + savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); + return result; + } + catch (HttpRequestException ex) + { + if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + { + _tokens.Clear(); + _lastErrorResponse = DateTime.UtcNow; + } + + throw; + } + finally + { + _tokenSemaphore.Release(); + } + } + + private async Task Send( + HttpRequestMessage options, + bool enableRetry, + ListingsProviderInfo providerInfo, + CancellationToken cancellationToken, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) + { + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return response; + } + + // Response is automatically disposed in the calling function, + // so dispose manually if not returning. +#pragma warning disable IDISP016, IDISP017 + response.Dispose(); + if (!enableRetry || (int)response.StatusCode >= 500) + { + throw new HttpRequestException( + string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), + null, + response.StatusCode); + } +#pragma warning restore IDISP016, IDISP017 + + _tokens.Clear(); + options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); + return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false); + } + + private async Task GetTokenInternal( + string username, + string password, + CancellationToken cancellationToken) + { + using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token"); +#pragma warning disable CA5350 // SchedulesDirect is always SHA1. + var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password)); +#pragma warning restore CA5350 + // TODO: remove ToLower when Convert.ToHexString supports lowercase + // Schedules Direct requires the hex to be lowercase + string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); + options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); + + using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var root = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (string.Equals(root?.Message, "OK", StringComparison.Ordinal)) + { + _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token); + return root.Token; + } + + throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message); + } + + private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken) + { + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + ArgumentException.ThrowIfNullOrEmpty(token); + ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); + + _logger.LogInformation("Adding new LineUp "); + + using var options = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId); + options.Headers.TryAddWithoutValidation("token", token); + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + } + + private async Task HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); + + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + ArgumentException.ThrowIfNullOrEmpty(token); + + _logger.LogInformation("Headends on account "); + + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups"); + options.Headers.TryAddWithoutValidation("token", token); + + try + { + using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false); + httpResponse.EnsureSuccessStatusCode(); + var root = await httpResponse.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false; + } + catch (HttpRequestException ex) + { + // SchedulesDirect returns 400 if no lineups are configured. + if (ex.StatusCode is HttpStatusCode.BadRequest) + { + return false; + } + + throw; + } + } + + public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + if (validateLogin) + { + ArgumentException.ThrowIfNullOrEmpty(info.Username); + ArgumentException.ThrowIfNullOrEmpty(info.Password); + } + + if (validateListings) + { + ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); + + var hasLineup = await HasLineup(info, CancellationToken.None).ConfigureAwait(false); + + if (!hasLineup) + { + await AddLineupToAccount(info, CancellationToken.None).ConfigureAwait(false); + } + } + } + + public Task> GetLineups(ListingsProviderInfo info, string country, string location) + { + return GetHeadends(info, country, location, CancellationToken.None); + } + + public async Task> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) + { + var listingsId = info.ListingsId; + ArgumentException.ThrowIfNullOrEmpty(listingsId); + + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + ArgumentException.ThrowIfNullOrEmpty(token); + + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId); + options.Headers.TryAddWithoutValidation("token", token); + + using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false); + var root = await httpResponse.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (root is null) + { + return new List(); + } + + _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count); + _logger.LogInformation("Mapping Stations to Channel"); + + var allStations = root.Stations; + + var map = root.Map; + var list = new List(map.Count); + foreach (var channel in map) + { + var channelNumber = GetChannelNumber(channel); + + var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, StringComparison.OrdinalIgnoreCase)); + var station = stationIndex == -1 + ? new StationDto { StationId = channel.StationId } + : allStations[stationIndex]; + + var channelInfo = new ChannelInfo + { + Id = station.StationId, + CallSign = station.Callsign, + Number = channelNumber, + Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name + }; + + if (station.Logo is not null) + { + channelInfo.ImageUrl = station.Logo.Url; + } + + list.Add(channelInfo); + } + + return list; + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and optionally managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _tokenSemaphore?.Dispose(); + } + + _disposed = true; + } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs new file mode 100644 index 0000000000..c1a502fd5a --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Broadcaster dto. + /// + public class BroadcasterDto + { + /// + /// Gets or sets the city. + /// + [JsonPropertyName("city")] + public string? City { get; set; } + + /// + /// Gets or sets the state. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + + /// + /// Gets or sets the postal code. + /// + [JsonPropertyName("postalCode")] + public string? Postalcode { get; set; } + + /// + /// Gets or sets the country. + /// + [JsonPropertyName("country")] + public string? Country { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs new file mode 100644 index 0000000000..0cc39f3bb5 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Caption dto. + /// + public class CaptionDto + { + /// + /// Gets or sets the content. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// Gets or sets the lang. + /// + [JsonPropertyName("lang")] + public string? Lang { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CastDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CastDto.cs new file mode 100644 index 0000000000..bdcf87fdac --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CastDto.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Cast dto. + /// + public class CastDto + { + /// + /// Gets or sets the billing order. + /// + [JsonPropertyName("billingOrder")] + public string? BillingOrder { get; set; } + + /// + /// Gets or sets the role. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// Gets or sets the name id. + /// + [JsonPropertyName("nameId")] + public string? NameId { get; set; } + + /// + /// Gets or sets the person id. + /// + [JsonPropertyName("personId")] + public string? PersonId { get; set; } + + /// + /// Gets or sets the name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the character name. + /// + [JsonPropertyName("characterName")] + public string? CharacterName { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs new file mode 100644 index 0000000000..4e0d740785 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Channel dto. + /// + public class ChannelDto + { + /// + /// Gets or sets the list of maps. + /// + [JsonPropertyName("map")] + public IReadOnlyList Map { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of stations. + /// + [JsonPropertyName("stations")] + public IReadOnlyList Stations { get; set; } = Array.Empty(); + + /// + /// Gets or sets the metadata. + /// + [JsonPropertyName("metadata")] + public MetadataDto? Metadata { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs new file mode 100644 index 0000000000..5c624c2885 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Content rating dto. + /// + public class ContentRatingDto + { + /// + /// Gets or sets the body. + /// + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// + /// Gets or sets the code. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs new file mode 100644 index 0000000000..6d3c79c185 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Crew dto. + /// + public class CrewDto + { + /// + /// Gets or sets the billing order. + /// + [JsonPropertyName("billingOrder")] + public string? BillingOrder { get; set; } + + /// + /// Gets or sets the role. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// Gets or sets the name id. + /// + [JsonPropertyName("nameId")] + public string? NameId { get; set; } + + /// + /// Gets or sets the person id. + /// + [JsonPropertyName("personId")] + public string? PersonId { get; set; } + + /// + /// Gets or sets the name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DayDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DayDto.cs new file mode 100644 index 0000000000..094f9a3194 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DayDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Day dto. + /// + public class DayDto + { + /// + /// Gets or sets the station id. + /// + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// + /// Gets or sets the list of programs. + /// + [JsonPropertyName("programs")] + public IReadOnlyList Programs { get; set; } = Array.Empty(); + + /// + /// Gets or sets the metadata schedule. + /// + [JsonPropertyName("metadata")] + public MetadataScheduleDto? Metadata { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs new file mode 100644 index 0000000000..0063f4cc34 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Description 1_000 dto. + /// + public class Description1000Dto + { + /// + /// Gets or sets the description language. + /// + [JsonPropertyName("descriptionLanguage")] + public string? DescriptionLanguage { get; set; } + + /// + /// Gets or sets the description. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs new file mode 100644 index 0000000000..1d9a18cc7f --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Description 100 dto. + /// + public class Description100Dto + { + /// + /// Gets or sets the description language. + /// + [JsonPropertyName("descriptionLanguage")] + public string? DescriptionLanguage { get; set; } + + /// + /// Gets or sets the description. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs new file mode 100644 index 0000000000..75e91547bb --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Descriptions program dto. + /// + public class DescriptionsProgramDto + { + /// + /// Gets or sets the list of description 100. + /// + [JsonPropertyName("description100")] + public IReadOnlyList Description100 { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of description1000. + /// + [JsonPropertyName("description1000")] + public IReadOnlyList Description1000 { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs new file mode 100644 index 0000000000..28abe094ed --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Event details dto. + /// + public class EventDetailsDto + { + /// + /// Gets or sets the sub type. + /// + [JsonPropertyName("subType")] + public string? SubType { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs new file mode 100644 index 0000000000..6eefc1744b --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Gracenote dto. + /// + public class GracenoteDto + { + /// + /// Gets or sets the season. + /// + [JsonPropertyName("season")] + public int Season { get; set; } + + /// + /// Gets or sets the episode. + /// + [JsonPropertyName("episode")] + public int Episode { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs new file mode 100644 index 0000000000..a62ae61f91 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Headends dto. + /// + public class HeadendsDto + { + /// + /// Gets or sets the headend. + /// + [JsonPropertyName("headend")] + public string? Headend { get; set; } + + /// + /// Gets or sets the transport. + /// + [JsonPropertyName("transport")] + public string? Transport { get; set; } + + /// + /// Gets or sets the location. + /// + [JsonPropertyName("location")] + public string? Location { get; set; } + + /// + /// Gets or sets the list of lineups. + /// + [JsonPropertyName("lineups")] + public IReadOnlyList Lineups { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs new file mode 100644 index 0000000000..21b595f244 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Image data dto. + /// + public class ImageDataDto + { + /// + /// Gets or sets the width. + /// + [JsonPropertyName("width")] + public string? Width { get; set; } + + /// + /// Gets or sets the height. + /// + [JsonPropertyName("height")] + public string? Height { get; set; } + + /// + /// Gets or sets the uri. + /// + [JsonPropertyName("uri")] + public string? Uri { get; set; } + + /// + /// Gets or sets the size. + /// + [JsonPropertyName("size")] + public string? Size { get; set; } + + /// + /// Gets or sets the aspect. + /// + [JsonPropertyName("aspect")] + public string? Aspect { get; set; } + + /// + /// Gets or sets the category. + /// + [JsonPropertyName("category")] + public string? Category { get; set; } + + /// + /// Gets or sets the text. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// Gets or sets the primary. + /// + [JsonPropertyName("primary")] + public string? Primary { get; set; } + + /// + /// Gets or sets the tier. + /// + [JsonPropertyName("tier")] + public string? Tier { get; set; } + + /// + /// Gets or sets the caption. + /// + [JsonPropertyName("caption")] + public CaptionDto? Caption { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs new file mode 100644 index 0000000000..856b7a89b0 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// The lineup dto. + /// + public class LineupDto + { + /// + /// Gets or sets the linup. + /// + [JsonPropertyName("lineup")] + public string? Lineup { get; set; } + + /// + /// Gets or sets the lineup name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the transport. + /// + [JsonPropertyName("transport")] + public string? Transport { get; set; } + + /// + /// Gets or sets the location. + /// + [JsonPropertyName("location")] + public string? Location { get; set; } + + /// + /// Gets or sets the uri. + /// + [JsonPropertyName("uri")] + public string? Uri { get; set; } + + /// + /// Gets or sets a value indicating whether this lineup was deleted. + /// + [JsonPropertyName("isDeleted")] + public bool? IsDeleted { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs new file mode 100644 index 0000000000..99f80ce8a1 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Lineups dto. + /// + public class LineupsDto + { + /// + /// Gets or sets the response code. + /// + [JsonPropertyName("code")] + public int Code { get; set; } + + /// + /// Gets or sets the server id. + /// + [JsonPropertyName("serverID")] + public string? ServerId { get; set; } + + /// + /// Gets or sets the datetime. + /// + [JsonPropertyName("datetime")] + public DateTime? LineupTimestamp { get; set; } + + /// + /// Gets or sets the list of lineups. + /// + [JsonPropertyName("lineups")] + public IReadOnlyList Lineups { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs new file mode 100644 index 0000000000..d7836384e2 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Logo dto. + /// + public class LogoDto + { + /// + /// Gets or sets the url. + /// + [JsonPropertyName("URL")] + public string? Url { get; set; } + + /// + /// Gets or sets the height. + /// + [JsonPropertyName("height")] + public int Height { get; set; } + + /// + /// Gets or sets the width. + /// + [JsonPropertyName("width")] + public int Width { get; set; } + + /// + /// Gets or sets the md5. + /// + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs new file mode 100644 index 0000000000..ea583a1cea --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Map dto. + /// + public class MapDto + { + /// + /// Gets or sets the station id. + /// + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// + /// Gets or sets the channel. + /// + [JsonPropertyName("channel")] + public string? Channel { get; set; } + + /// + /// Gets or sets the provider callsign. + /// + [JsonPropertyName("providerCallsign")] + public string? ProvderCallsign { get; set; } + + /// + /// Gets or sets the logical channel number. + /// + [JsonPropertyName("logicalChannelNumber")] + public string? LogicalChannelNumber { get; set; } + + /// + /// Gets or sets the uhfvhf. + /// + [JsonPropertyName("uhfVhf")] + public int UhfVhf { get; set; } + + /// + /// Gets or sets the atsc major. + /// + [JsonPropertyName("atscMajor")] + public int AtscMajor { get; set; } + + /// + /// Gets or sets the atsc minor. + /// + [JsonPropertyName("atscMinor")] + public int AtscMinor { get; set; } + + /// + /// Gets or sets the match type. + /// + [JsonPropertyName("matchType")] + public string? MatchType { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs new file mode 100644 index 0000000000..cafc8e2738 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Metadata dto. + /// + public class MetadataDto + { + /// + /// Gets or sets the linup. + /// + [JsonPropertyName("lineup")] + public string? Lineup { get; set; } + + /// + /// Gets or sets the modified timestamp. + /// + [JsonPropertyName("modified")] + public string? Modified { get; set; } + + /// + /// Gets or sets the transport. + /// + [JsonPropertyName("transport")] + public string? Transport { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs new file mode 100644 index 0000000000..243ccff5c0 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Metadata programs dto. + /// + public class MetadataProgramsDto + { + /// + /// Gets or sets the gracenote object. + /// + [JsonPropertyName("Gracenote")] + public GracenoteDto? Gracenote { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs new file mode 100644 index 0000000000..1c5c5333ce --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs @@ -0,0 +1,41 @@ +using System; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Metadata schedule dto. + /// + public class MetadataScheduleDto + { + /// + /// Gets or sets the modified timestamp. + /// + [JsonPropertyName("modified")] + public string? Modified { get; set; } + + /// + /// Gets or sets the md5. + /// + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// + /// Gets or sets the start date. + /// + [JsonPropertyName("startDate")] + public DateTime? StartDate { get; set; } + + /// + /// Gets or sets the end date. + /// + [JsonPropertyName("endDate")] + public DateTime? EndDate { get; set; } + + /// + /// Gets or sets the days count. + /// + [JsonPropertyName("days")] + public int Days { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs new file mode 100644 index 0000000000..aea7408336 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Movie dto. + /// + public class MovieDto + { + /// + /// Gets or sets the year. + /// + [JsonPropertyName("year")] + public string? Year { get; set; } + + /// + /// Gets or sets the duration. + /// + [JsonPropertyName("duration")] + public int Duration { get; set; } + + /// + /// Gets or sets the list of quality rating. + /// + [JsonPropertyName("qualityRating")] + public IReadOnlyList QualityRating { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs new file mode 100644 index 0000000000..328cefadc3 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Multipart dto. + /// + public class MultipartDto + { + /// + /// Gets or sets the part number. + /// + [JsonPropertyName("partNumber")] + public int PartNumber { get; set; } + + /// + /// Gets or sets the total parts. + /// + [JsonPropertyName("totalParts")] + public int TotalParts { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs new file mode 100644 index 0000000000..8c3906f863 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Program details dto. + /// + public class ProgramDetailsDto + { + /// + /// Gets or sets the audience. + /// + [JsonPropertyName("audience")] + public string? Audience { get; set; } + + /// + /// Gets or sets the program id. + /// + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// + /// Gets or sets the list of titles. + /// + [JsonPropertyName("titles")] + public IReadOnlyList Titles { get; set; } = Array.Empty(); + + /// + /// Gets or sets the event details object. + /// + [JsonPropertyName("eventDetails")] + public EventDetailsDto? EventDetails { get; set; } + + /// + /// Gets or sets the descriptions. + /// + [JsonPropertyName("descriptions")] + public DescriptionsProgramDto? Descriptions { get; set; } + + /// + /// Gets or sets the original air date. + /// + [JsonPropertyName("originalAirDate")] + public DateTime? OriginalAirDate { get; set; } + + /// + /// Gets or sets the list of genres. + /// + [JsonPropertyName("genres")] + public IReadOnlyList Genres { get; set; } = Array.Empty(); + + /// + /// Gets or sets the episode title. + /// + [JsonPropertyName("episodeTitle150")] + public string? EpisodeTitle150 { get; set; } + + /// + /// Gets or sets the list of metadata. + /// + [JsonPropertyName("metadata")] + public IReadOnlyList Metadata { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of content raitings. + /// + [JsonPropertyName("contentRating")] + public IReadOnlyList ContentRating { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of cast. + /// + [JsonPropertyName("cast")] + public IReadOnlyList Cast { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of crew. + /// + [JsonPropertyName("crew")] + public IReadOnlyList Crew { get; set; } = Array.Empty(); + + /// + /// Gets or sets the entity type. + /// + [JsonPropertyName("entityType")] + public string? EntityType { get; set; } + + /// + /// Gets or sets the show type. + /// + [JsonPropertyName("showType")] + public string? ShowType { get; set; } + + /// + /// Gets or sets a value indicating whether there is image artwork. + /// + [JsonPropertyName("hasImageArtwork")] + public bool HasImageArtwork { get; set; } + + /// + /// Gets or sets the primary image. + /// + [JsonPropertyName("primaryImage")] + public string? PrimaryImage { get; set; } + + /// + /// Gets or sets the thumb image. + /// + [JsonPropertyName("thumbImage")] + public string? ThumbImage { get; set; } + + /// + /// Gets or sets the backdrop image. + /// + [JsonPropertyName("backdropImage")] + public string? BackdropImage { get; set; } + + /// + /// Gets or sets the banner image. + /// + [JsonPropertyName("bannerImage")] + public string? BannerImage { get; set; } + + /// + /// Gets or sets the image id. + /// + [JsonPropertyName("imageID")] + public string? ImageId { get; set; } + + /// + /// Gets or sets the md5. + /// + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// + /// Gets or sets the list of content advisory. + /// + [JsonPropertyName("contentAdvisory")] + public IReadOnlyList ContentAdvisory { get; set; } = Array.Empty(); + + /// + /// Gets or sets the movie object. + /// + [JsonPropertyName("movie")] + public MovieDto? Movie { get; set; } + + /// + /// Gets or sets the list of recommendations. + /// + [JsonPropertyName("recommendations")] + public IReadOnlyList Recommendations { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs new file mode 100644 index 0000000000..527a6f8a19 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Program dto. + /// + public class ProgramDto + { + /// + /// Gets or sets the program id. + /// + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// + /// Gets or sets the air date time. + /// + [JsonPropertyName("airDateTime")] + public DateTime? AirDateTime { get; set; } + + /// + /// Gets or sets the duration. + /// + [JsonPropertyName("duration")] + public int Duration { get; set; } + + /// + /// Gets or sets the md5. + /// + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// + /// Gets or sets the list of audio properties. + /// + [JsonPropertyName("audioProperties")] + public IReadOnlyList AudioProperties { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of video properties. + /// + [JsonPropertyName("videoProperties")] + public IReadOnlyList VideoProperties { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of ratings. + /// + [JsonPropertyName("ratings")] + public IReadOnlyList Ratings { get; set; } = Array.Empty(); + + /// + /// Gets or sets a value indicating whether this program is new. + /// + [JsonPropertyName("new")] + public bool? New { get; set; } + + /// + /// Gets or sets the multipart object. + /// + [JsonPropertyName("multipart")] + public MultipartDto? Multipart { get; set; } + + /// + /// Gets or sets the live tape delay. + /// + [JsonPropertyName("liveTapeDelay")] + public string? LiveTapeDelay { get; set; } + + /// + /// Gets or sets a value indicating whether this is the premiere. + /// + [JsonPropertyName("premiere")] + public bool Premiere { get; set; } + + /// + /// Gets or sets a value indicating whether this is a repeat. + /// + [JsonPropertyName("repeat")] + public bool Repeat { get; set; } + + /// + /// Gets or sets the premiere or finale. + /// + [JsonPropertyName("isPremiereOrFinale")] + public string? IsPremiereOrFinale { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs new file mode 100644 index 0000000000..61496155ac --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Quality rating dto. + /// + public class QualityRatingDto + { + /// + /// Gets or sets the ratings body. + /// + [JsonPropertyName("ratingsBody")] + public string? RatingsBody { get; set; } + + /// + /// Gets or sets the rating. + /// + [JsonPropertyName("rating")] + public string? Rating { get; set; } + + /// + /// Gets or sets the min rating. + /// + [JsonPropertyName("minRating")] + public string? MinRating { get; set; } + + /// + /// Gets or sets the max rating. + /// + [JsonPropertyName("maxRating")] + public string? MaxRating { get; set; } + + /// + /// Gets or sets the increment. + /// + [JsonPropertyName("increment")] + public string? Increment { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs new file mode 100644 index 0000000000..287cd4ed5c --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Rating dto. + /// + public class RatingDto + { + /// + /// Gets or sets the body. + /// + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// + /// Gets or sets the code. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs new file mode 100644 index 0000000000..d380ec7aee --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Recommendation dto. + /// + public class RecommendationDto + { + /// + /// Gets or sets the program id. + /// + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// + /// Gets or sets the title. + /// + [JsonPropertyName("title120")] + public string? Title120 { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs new file mode 100644 index 0000000000..6fc695a393 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Request schedule for channel dto. + /// + public class RequestScheduleForChannelDto + { + /// + /// Gets or sets the station id. + /// + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// + /// Gets or sets the list of dates. + /// + [JsonPropertyName("date")] + public IReadOnlyList Date { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs new file mode 100644 index 0000000000..523900a96a --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Show image dto. + /// + public class ShowImagesDto + { + /// + /// Gets or sets the program id. + /// + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// + /// Gets or sets the list of data. + /// + [JsonPropertyName("data")] + public IReadOnlyList Data { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/StationDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/StationDto.cs new file mode 100644 index 0000000000..dbde1e117d --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/StationDto.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Station dto. + /// + public class StationDto + { + /// + /// Gets or sets the station id. + /// + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// + /// Gets or sets the name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the callsign. + /// + [JsonPropertyName("callsign")] + public string? Callsign { get; set; } + + /// + /// Gets or sets the broadcast language. + /// + [JsonPropertyName("broadcastLanguage")] + public IReadOnlyList BroadcastLanguage { get; set; } = Array.Empty(); + + /// + /// Gets or sets the description language. + /// + [JsonPropertyName("descriptionLanguage")] + public IReadOnlyList DescriptionLanguage { get; set; } = Array.Empty(); + + /// + /// Gets or sets the broadcaster. + /// + [JsonPropertyName("broadcaster")] + public BroadcasterDto? Broadcaster { get; set; } + + /// + /// Gets or sets the affiliate. + /// + [JsonPropertyName("affiliate")] + public string? Affiliate { get; set; } + + /// + /// Gets or sets the logo. + /// + [JsonPropertyName("logo")] + public LogoDto? Logo { get; set; } + + /// + /// Gets or sets a value indicating whether it is commercial free. + /// + [JsonPropertyName("isCommercialFree")] + public bool? IsCommercialFree { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs new file mode 100644 index 0000000000..146124f987 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Title dto. + /// + public class TitleDto + { + /// + /// Gets or sets the title. + /// + [JsonPropertyName("title120")] + public string? Title120 { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs new file mode 100644 index 0000000000..b3bc618376 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs @@ -0,0 +1,47 @@ +using System; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// The token dto. + /// + public class TokenDto + { + /// + /// Gets or sets the response code. + /// + [JsonPropertyName("code")] + public int Code { get; set; } + + /// + /// Gets or sets the response message. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// + /// Gets or sets the server id. + /// + [JsonPropertyName("serverID")] + public string? ServerId { get; set; } + + /// + /// Gets or sets the token. + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + + /// + /// Gets or sets the current datetime. + /// + [JsonPropertyName("datetime")] + public DateTime? TokenTimestamp { get; set; } + + /// + /// Gets or sets the response message. + /// + [JsonPropertyName("response")] + public string? Response { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs new file mode 100644 index 0000000000..cecc363f07 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -0,0 +1,267 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using Jellyfin.XmlTv; +using Jellyfin.XmlTv.Entities; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Listings +{ + public class XmlTvListingsProvider : IListingsProvider + { + private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1); + + private readonly IServerConfigurationManager _config; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public XmlTvListingsProvider( + IServerConfigurationManager config, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _config = config; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public string Name => "XmlTV"; + + public string Type => "xmltv"; + + private string GetLanguage(ListingsProviderInfo info) + { + if (!string.IsNullOrWhiteSpace(info.PreferredLanguage)) + { + return info.PreferredLanguage; + } + + return _config.Configuration.PreferredMetadataLanguage; + } + + private async Task GetXml(ListingsProviderInfo info, CancellationToken cancellationToken) + { + _logger.LogInformation("xmltv path: {Path}", info.Path); + + string cacheFilename = info.Id + ".xml"; + string cacheFile = Path.Combine(_config.ApplicationPaths.CachePath, "xmltv", cacheFilename); + + if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge)) + { + return cacheFile; + } + + // Must check if file exists as parent directory may not exist. + if (File.Exists(cacheFile)) + { + File.Delete(cacheFile); + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); + } + + if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); + + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + } + } + else + { + var stream = AsyncFile.OpenRead(info.Path); + await using (stream.ConfigureAwait(false)) + { + return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken) + { + var fileStream = new FileStream( + file, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous); + + await using (fileStream.ConfigureAwait(false)) + { + if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase)) + { + try + { + using var reader = new GZipStream(stream, CompressionMode.Decompress); + await reader.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error extracting from gz file {File}", originalUrl); + } + } + else + { + await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + + return file; + } + } + + public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(channelId)) + { + throw new ArgumentNullException(nameof(channelId)); + } + + _logger.LogDebug("Getting xmltv programs for channel {Id}", channelId); + + string path = await GetXml(info, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Opening XmlTvReader for {Path}", path); + var reader = new XmlTvReader(path, GetLanguage(info)); + + return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken) + .Select(p => GetProgramInfo(p, info)); + } + + private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info) + { + string episodeTitle = program.Episode.Title; + var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList(); + + var programInfo = new ProgramInfo + { + ChannelId = program.ChannelId, + EndDate = program.EndDate.UtcDateTime, + EpisodeNumber = program.Episode.Episode, + EpisodeTitle = episodeTitle, + Genres = programCategories, + StartDate = program.StartDate.UtcDateTime, + Name = program.Title, + Overview = program.Description, + ProductionYear = program.CopyrightDate?.Year, + SeasonNumber = program.Episode.Series, + IsSeries = program.Episode.Series is not null, + IsRepeat = program.IsPreviouslyShown && !program.IsNew, + IsPremiere = program.Premiere is not null, + IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + ImageUrl = string.IsNullOrEmpty(program.Icon?.Source) ? null : program.Icon.Source, + HasImage = !string.IsNullOrEmpty(program.Icon?.Source), + OfficialRating = string.IsNullOrEmpty(program.Rating?.Value) ? null : program.Rating.Value, + CommunityRating = program.StarRating, + SeriesId = program.Episode.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture) + }; + + if (string.IsNullOrWhiteSpace(program.ProgramId)) + { + string uniqueString = (program.Title ?? string.Empty) + (episodeTitle ?? string.Empty); + + if (programInfo.SeasonNumber.HasValue) + { + uniqueString = "-" + programInfo.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture); + } + + if (programInfo.EpisodeNumber.HasValue) + { + uniqueString = "-" + programInfo.EpisodeNumber.Value.ToString(CultureInfo.InvariantCulture); + } + + programInfo.ShowId = uniqueString.GetMD5().ToString("N", CultureInfo.InvariantCulture); + + // If we don't have valid episode info, assume it's a unique program, otherwise recordings might be skipped + if (programInfo.IsSeries + && !programInfo.IsRepeat + && (programInfo.EpisodeNumber ?? 0) == 0) + { + programInfo.ShowId += programInfo.StartDate.Ticks.ToString(CultureInfo.InvariantCulture); + } + } + else + { + programInfo.ShowId = program.ProgramId; + } + + // Construct an id from the channel and start date + programInfo.Id = string.Format(CultureInfo.InvariantCulture, "{0}_{1:O}", program.ChannelId, program.StartDate); + + if (programInfo.IsMovie) + { + programInfo.IsSeries = false; + programInfo.EpisodeNumber = null; + programInfo.EpisodeTitle = null; + } + + return programInfo; + } + + public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + // Assume all urls are valid. check files for existence + if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path)) + { + throw new FileNotFoundException("Could not find the XmlTv file specified:", info.Path); + } + + return Task.CompletedTask; + } + + public async Task> GetLineups(ListingsProviderInfo info, string country, string location) + { + // In theory this should never be called because there is always only one lineup + string path = await GetXml(info, CancellationToken.None).ConfigureAwait(false); + _logger.LogDebug("Opening XmlTvReader for {Path}", path); + var reader = new XmlTvReader(path, GetLanguage(info)); + IEnumerable results = reader.GetChannels(); + + // Should this method be async? + return results.Select(c => new NameIdPair() { Id = c.Id, Name = c.DisplayName }).ToList(); + } + + public async Task> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) + { + // In theory this should never be called because there is always only one lineup + string path = await GetXml(info, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Opening XmlTvReader for {Path}", path); + var reader = new XmlTvReader(path, GetLanguage(info)); + var results = reader.GetChannels(); + + // Should this method be async? + return results.Select(c => new ChannelInfo + { + Id = c.Id, + Name = c.DisplayName, + ImageUrl = string.IsNullOrEmpty(c.Icon?.Source) ? null : c.Icon.Source, + Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number + }).ToList(); + } + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs b/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs new file mode 100644 index 0000000000..ddbf6345c5 --- /dev/null +++ b/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.LiveTv; + +namespace Jellyfin.LiveTv +{ + /// + /// implementation for . + /// + public class LiveTvConfigurationFactory : IConfigurationFactory + { + /// + public IEnumerable GetConfigurations() + { + return new ConfigurationStore[] + { + new ConfigurationStore + { + ConfigurationType = typeof(LiveTvOptions), + Key = "livetv" + } + }; + } + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvDtoService.cs b/src/Jellyfin.LiveTv/LiveTvDtoService.cs new file mode 100644 index 0000000000..7c7c26eb4a --- /dev/null +++ b/src/Jellyfin.LiveTv/LiveTvDtoService.cs @@ -0,0 +1,548 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Common; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv +{ + public class LiveTvDtoService + { + private const string InternalVersionNumber = "4"; + + private const string ServiceName = "Emby"; + + private readonly ILogger _logger; + private readonly IImageProcessor _imageProcessor; + private readonly IDtoService _dtoService; + private readonly IApplicationHost _appHost; + private readonly ILibraryManager _libraryManager; + + public LiveTvDtoService( + IDtoService dtoService, + IImageProcessor imageProcessor, + ILogger logger, + IApplicationHost appHost, + ILibraryManager libraryManager) + { + _dtoService = dtoService; + _imageProcessor = imageProcessor; + _logger = logger; + _appHost = appHost; + _libraryManager = libraryManager; + } + + public TimerInfoDto GetTimerInfoDto(TimerInfo info, ILiveTvService service, LiveTvProgram program, BaseItem channel) + { + var dto = new TimerInfoDto + { + Id = GetInternalTimerId(info.Id), + Overview = info.Overview, + EndDate = info.EndDate, + Name = info.Name, + StartDate = info.StartDate, + ExternalId = info.Id, + ChannelId = GetInternalChannelId(service.Name, info.ChannelId), + Status = info.Status, + SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null : GetInternalSeriesTimerId(info.SeriesTimerId).ToString("N", CultureInfo.InvariantCulture), + PrePaddingSeconds = info.PrePaddingSeconds, + PostPaddingSeconds = info.PostPaddingSeconds, + IsPostPaddingRequired = info.IsPostPaddingRequired, + IsPrePaddingRequired = info.IsPrePaddingRequired, + KeepUntil = info.KeepUntil, + ExternalChannelId = info.ChannelId, + ExternalSeriesTimerId = info.SeriesTimerId, + ServiceName = service.Name, + ExternalProgramId = info.ProgramId, + Priority = info.Priority, + RunTimeTicks = (info.EndDate - info.StartDate).Ticks, + ServerId = _appHost.SystemId + }; + + if (!string.IsNullOrEmpty(info.ProgramId)) + { + dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N", CultureInfo.InvariantCulture); + } + + if (program is not null) + { + dto.ProgramInfo = _dtoService.GetBaseItemDto(program, new DtoOptions()); + + if (info.Status != RecordingStatus.Cancelled && info.Status != RecordingStatus.Error) + { + dto.ProgramInfo.TimerId = dto.Id; + dto.ProgramInfo.Status = info.Status.ToString(); + } + + dto.ProgramInfo.SeriesTimerId = dto.SeriesTimerId; + + if (!string.IsNullOrEmpty(info.SeriesTimerId)) + { + FillImages(dto.ProgramInfo, info.Name, info.SeriesId); + } + } + + if (channel is not null) + { + dto.ChannelName = channel.Name; + + if (channel.HasImage(ImageType.Primary)) + { + dto.ChannelPrimaryImageTag = GetImageTag(channel); + } + } + + return dto; + } + + public SeriesTimerInfoDto GetSeriesTimerInfoDto(SeriesTimerInfo info, ILiveTvService service, string channelName) + { + var dto = new SeriesTimerInfoDto + { + Id = GetInternalSeriesTimerId(info.Id).ToString("N", CultureInfo.InvariantCulture), + Overview = info.Overview, + EndDate = info.EndDate, + Name = info.Name, + StartDate = info.StartDate, + ExternalId = info.Id, + PrePaddingSeconds = info.PrePaddingSeconds, + PostPaddingSeconds = info.PostPaddingSeconds, + IsPostPaddingRequired = info.IsPostPaddingRequired, + IsPrePaddingRequired = info.IsPrePaddingRequired, + Days = info.Days.ToArray(), + Priority = info.Priority, + RecordAnyChannel = info.RecordAnyChannel, + RecordAnyTime = info.RecordAnyTime, + SkipEpisodesInLibrary = info.SkipEpisodesInLibrary, + KeepUpTo = info.KeepUpTo, + KeepUntil = info.KeepUntil, + RecordNewOnly = info.RecordNewOnly, + ExternalChannelId = info.ChannelId, + ExternalProgramId = info.ProgramId, + ServiceName = service.Name, + ChannelName = channelName, + ServerId = _appHost.SystemId + }; + + if (!string.IsNullOrEmpty(info.ChannelId)) + { + dto.ChannelId = GetInternalChannelId(service.Name, info.ChannelId); + } + + if (!string.IsNullOrEmpty(info.ProgramId)) + { + dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N", CultureInfo.InvariantCulture); + } + + dto.DayPattern = info.Days is null ? null : GetDayPattern(info.Days.ToArray()); + + FillImages(dto, info.Name, info.SeriesId); + + return dto; + } + + private void FillImages(BaseItemDto dto, string seriesName, string programSeriesId) + { + var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + Name = seriesName, + Limit = 1, + ImageTypes = new ImageType[] { ImageType.Thumb }, + DtoOptions = new DtoOptions(false) + }).FirstOrDefault(); + + if (librarySeries is not null) + { + var image = librarySeries.GetImageInfo(ImageType.Thumb, 0); + if (image is not null) + { + try + { + dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image); + dto.ParentThumbItemId = librarySeries.Id; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + + image = librarySeries.GetImageInfo(ImageType.Backdrop, 0); + if (image is not null) + { + try + { + dto.ParentBackdropImageTags = new string[] + { + _imageProcessor.GetImageCacheTag(librarySeries, image) + }; + dto.ParentBackdropItemId = librarySeries.Id; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + } + + var program = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + ExternalSeriesId = programSeriesId, + Limit = 1, + ImageTypes = new ImageType[] { ImageType.Primary }, + DtoOptions = new DtoOptions(false), + Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null + }).FirstOrDefault(); + + if (program is not null) + { + var image = program.GetImageInfo(ImageType.Primary, 0); + if (image is not null) + { + try + { + dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); + dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + + if (dto.ParentBackdropImageTags is null || dto.ParentBackdropImageTags.Length == 0) + { + image = program.GetImageInfo(ImageType.Backdrop, 0); + if (image is not null) + { + try + { + dto.ParentBackdropImageTags = new string[] + { + _imageProcessor.GetImageCacheTag(program, image) + }; + + dto.ParentBackdropItemId = program.Id; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + } + } + } + + private void FillImages(SeriesTimerInfoDto dto, string seriesName, string programSeriesId) + { + var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + Name = seriesName, + Limit = 1, + ImageTypes = new ImageType[] { ImageType.Thumb }, + DtoOptions = new DtoOptions(false) + }).FirstOrDefault(); + + if (librarySeries is not null) + { + var image = librarySeries.GetImageInfo(ImageType.Thumb, 0); + if (image is not null) + { + try + { + dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image); + dto.ParentThumbItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + + image = librarySeries.GetImageInfo(ImageType.Backdrop, 0); + if (image is not null) + { + try + { + dto.ParentBackdropImageTags = new string[] + { + _imageProcessor.GetImageCacheTag(librarySeries, image) + }; + dto.ParentBackdropItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + } + + var program = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + Name = seriesName, + Limit = 1, + ImageTypes = new ImageType[] { ImageType.Primary }, + DtoOptions = new DtoOptions(false) + }).FirstOrDefault(); + + if (program is null) + { + program = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + ExternalSeriesId = programSeriesId, + Limit = 1, + ImageTypes = new ImageType[] { ImageType.Primary }, + DtoOptions = new DtoOptions(false), + Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null + }).FirstOrDefault(); + } + + if (program is not null) + { + var image = program.GetImageInfo(ImageType.Primary, 0); + if (image is not null) + { + try + { + dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); + dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "GetImageCacheTag raised an exception in LiveTvDtoService.FillImages."); + } + } + + if (dto.ParentBackdropImageTags is null || dto.ParentBackdropImageTags.Length == 0) + { + image = program.GetImageInfo(ImageType.Backdrop, 0); + if (image is not null) + { + try + { + dto.ParentBackdropImageTags = new[] + { + _imageProcessor.GetImageCacheTag(program, image) + }; + dto.ParentBackdropItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + } + } + } + + public DayPattern? GetDayPattern(DayOfWeek[] days) + { + DayPattern? pattern = null; + + if (days.Length > 0) + { + if (days.Length == 7) + { + pattern = DayPattern.Daily; + } + else if (days.Length == 2) + { + if (days.Contains(DayOfWeek.Saturday) && days.Contains(DayOfWeek.Sunday)) + { + pattern = DayPattern.Weekends; + } + } + else if (days.Length == 5) + { + if (days.Contains(DayOfWeek.Monday) && days.Contains(DayOfWeek.Tuesday) && days.Contains(DayOfWeek.Wednesday) && days.Contains(DayOfWeek.Thursday) && days.Contains(DayOfWeek.Friday)) + { + pattern = DayPattern.Weekdays; + } + } + } + + return pattern; + } + + internal string GetImageTag(BaseItem info) + { + try + { + return _imageProcessor.GetImageCacheTag(info, ImageType.Primary); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting image info for {Name}", info.Name); + } + + return null; + } + + public Guid GetInternalChannelId(string serviceName, string externalId) + { + var name = serviceName + externalId + InternalVersionNumber; + + return _libraryManager.GetNewItemId(name.ToLowerInvariant(), typeof(LiveTvChannel)); + } + + public string GetInternalTimerId(string externalId) + { + var name = ServiceName + externalId + InternalVersionNumber; + + return name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture); + } + + public Guid GetInternalSeriesTimerId(string externalId) + { + var name = ServiceName + externalId + InternalVersionNumber; + + return name.ToLowerInvariant().GetMD5(); + } + + public Guid GetInternalProgramId(string externalId) + { + var name = ServiceName + externalId + InternalVersionNumber; + + return _libraryManager.GetNewItemId(name.ToLowerInvariant(), typeof(LiveTvProgram)); + } + + public async Task GetTimerInfo(TimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken) + { + var info = new TimerInfo + { + Overview = dto.Overview, + EndDate = dto.EndDate, + Name = dto.Name, + StartDate = dto.StartDate, + Status = dto.Status, + PrePaddingSeconds = dto.PrePaddingSeconds, + PostPaddingSeconds = dto.PostPaddingSeconds, + IsPostPaddingRequired = dto.IsPostPaddingRequired, + IsPrePaddingRequired = dto.IsPrePaddingRequired, + KeepUntil = dto.KeepUntil, + Priority = dto.Priority, + SeriesTimerId = dto.ExternalSeriesTimerId, + ProgramId = dto.ExternalProgramId, + ChannelId = dto.ExternalChannelId, + Id = dto.ExternalId + }; + + // Convert internal server id's to external tv provider id's + if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id)) + { + var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false); + + info.Id = timer.ExternalId; + } + + if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) + { + var channel = _libraryManager.GetItemById(dto.ChannelId); + + if (channel is not null) + { + info.ChannelId = channel.ExternalId; + } + } + + if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId)) + { + var program = _libraryManager.GetItemById(dto.ProgramId); + + if (program is not null) + { + info.ProgramId = program.ExternalId; + } + } + + if (!string.IsNullOrEmpty(dto.SeriesTimerId) && string.IsNullOrEmpty(info.SeriesTimerId)) + { + var timer = await liveTv.GetSeriesTimer(dto.SeriesTimerId, cancellationToken).ConfigureAwait(false); + + if (timer is not null) + { + info.SeriesTimerId = timer.ExternalId; + } + } + + return info; + } + + public async Task GetSeriesTimerInfo(SeriesTimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken) + { + var info = new SeriesTimerInfo + { + Overview = dto.Overview, + EndDate = dto.EndDate, + Name = dto.Name, + StartDate = dto.StartDate, + PrePaddingSeconds = dto.PrePaddingSeconds, + PostPaddingSeconds = dto.PostPaddingSeconds, + IsPostPaddingRequired = dto.IsPostPaddingRequired, + IsPrePaddingRequired = dto.IsPrePaddingRequired, + Days = dto.Days.ToList(), + Priority = dto.Priority, + RecordAnyChannel = dto.RecordAnyChannel, + RecordAnyTime = dto.RecordAnyTime, + SkipEpisodesInLibrary = dto.SkipEpisodesInLibrary, + KeepUpTo = dto.KeepUpTo, + KeepUntil = dto.KeepUntil, + RecordNewOnly = dto.RecordNewOnly, + ProgramId = dto.ExternalProgramId, + ChannelId = dto.ExternalChannelId, + Id = dto.ExternalId + }; + + // Convert internal server id's to external tv provider id's + if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id)) + { + var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false); + + info.Id = timer.ExternalId; + } + + if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) + { + var channel = _libraryManager.GetItemById(dto.ChannelId); + + if (channel is not null) + { + info.ChannelId = channel.ExternalId; + } + } + + if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId)) + { + var program = _libraryManager.GetItemById(dto.ProgramId); + + if (program is not null) + { + info.ProgramId = program.ExternalId; + } + } + + return info; + } + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs new file mode 100644 index 0000000000..4fc9956538 --- /dev/null +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -0,0 +1,2409 @@ +#nullable disable + +#pragma warning disable CS1591 + +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 MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv +{ + /// + /// Class LiveTvManager. + /// + public class LiveTvManager : ILiveTvManager + { + private const int MaxGuideDays = 14; + private const string ExternalServiceTag = "ExternalServiceId"; + + private const string EtagKey = "ProgramEtag"; + + private readonly IServerConfigurationManager _config; + private readonly ILogger _logger; + private readonly IItemRepository _itemRepo; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly IUserDataManager _userDataManager; + private readonly ILibraryManager _libraryManager; + private readonly ITaskManager _taskManager; + private readonly ILocalizationManager _localization; + private readonly IFileSystem _fileSystem; + private readonly IChannelManager _channelManager; + private readonly LiveTvDtoService _tvDtoService; + + private ILiveTvService[] _services = Array.Empty(); + private ITunerHost[] _tunerHosts = Array.Empty(); + private IListingsProvider[] _listingProviders = Array.Empty(); + + public LiveTvManager( + IServerConfigurationManager config, + ILogger logger, + IItemRepository itemRepo, + IUserDataManager userDataManager, + IDtoService dtoService, + IUserManager userManager, + ILibraryManager libraryManager, + ITaskManager taskManager, + ILocalizationManager localization, + IFileSystem fileSystem, + IChannelManager channelManager, + LiveTvDtoService liveTvDtoService) + { + _config = config; + _logger = logger; + _itemRepo = itemRepo; + _userManager = userManager; + _libraryManager = libraryManager; + _taskManager = taskManager; + _localization = localization; + _fileSystem = fileSystem; + _dtoService = dtoService; + _userDataManager = userDataManager; + _channelManager = channelManager; + _tvDtoService = liveTvDtoService; + } + + public event EventHandler> SeriesTimerCancelled; + + public event EventHandler> TimerCancelled; + + public event EventHandler> TimerCreated; + + public event EventHandler> SeriesTimerCreated; + + /// + /// Gets the services. + /// + /// The services. + public IReadOnlyList Services => _services; + + public IReadOnlyList TunerHosts => _tunerHosts; + + public IReadOnlyList ListingProviders => _listingProviders; + + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration("livetv"); + } + + public string GetEmbyTvActiveRecordingPath(string id) + { + return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); + } + + /// + /// Adds the parts. + /// + /// The services. + /// The tuner hosts. + /// The listing providers. + public void AddParts(IEnumerable services, IEnumerable tunerHosts, IEnumerable listingProviders) + { + _services = services.ToArray(); + _tunerHosts = tunerHosts.Where(i => i.IsSupported).ToArray(); + + _listingProviders = listingProviders.ToArray(); + + foreach (var service in _services) + { + if (service is EmbyTV.EmbyTV embyTv) + { + embyTv.TimerCreated += OnEmbyTvTimerCreated; + embyTv.TimerCancelled += OnEmbyTvTimerCancelled; + } + } + } + + private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs e) + { + var timerId = e.Argument; + + TimerCancelled?.Invoke(this, new GenericEventArgs(new TimerEventInfo(timerId))); + } + + private void OnEmbyTvTimerCreated(object sender, GenericEventArgs e) + { + var timer = e.Argument; + + TimerCreated?.Invoke(this, new GenericEventArgs( + new TimerEventInfo(timer.Id) + { + ProgramId = _tvDtoService.GetInternalProgramId(timer.ProgramId) + })); + } + + public List GetTunerHostTypes() + { + return _tunerHosts.OrderBy(i => i.Name).Select(i => new NameIdPair + { + Name = i.Name, + Id = i.Type + }).ToList(); + } + + public Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) + { + return EmbyTV.EmbyTV.Current.DiscoverTuners(newDevicesOnly, cancellationToken); + } + + public QueryResult GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken) + { + var user = query.UserId.Equals(default) + ? null + : _userManager.GetUserById(query.UserId); + + var topFolder = GetInternalLiveTvFolder(cancellationToken); + + var internalQuery = new InternalItemsQuery(user) + { + IsMovie = query.IsMovie, + IsNews = query.IsNews, + IsKids = query.IsKids, + IsSports = query.IsSports, + IsSeries = query.IsSeries, + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, + TopParentIds = new[] { topFolder.Id }, + IsFavorite = query.IsFavorite, + IsLiked = query.IsLiked, + StartIndex = query.StartIndex, + Limit = query.Limit, + DtoOptions = dtoOptions + }; + + var orderBy = internalQuery.OrderBy.ToList(); + + orderBy.AddRange(query.SortBy.Select(i => (i, query.SortOrder ?? SortOrder.Ascending))); + + if (query.EnableFavoriteSorting) + { + orderBy.Insert(0, (ItemSortBy.IsFavoriteOrLiked, SortOrder.Descending)); + } + + if (internalQuery.OrderBy.All(i => i.OrderBy != ItemSortBy.SortName)) + { + orderBy.Add((ItemSortBy.SortName, SortOrder.Ascending)); + } + + internalQuery.OrderBy = orderBy.ToArray(); + + return _libraryManager.GetItemsResult(internalQuery); + } + + public async Task> GetChannelStream(string id, string mediaSourceId, List currentLiveStreams, CancellationToken cancellationToken) + { + if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + { + mediaSourceId = null; + } + + var channel = (LiveTvChannel)_libraryManager.GetItemById(id); + + bool isVideo = channel.ChannelType == ChannelType.TV; + var service = GetService(channel); + _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId); + + MediaSourceInfo info; +#pragma warning disable CA1859 // TODO: Analyzer bug? + ILiveStream liveStream; +#pragma warning restore CA1859 + if (service is ISupportsDirectStreamProvider supportsManagedStream) + { + liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); + info = liveStream.MediaSource; + } + else + { + info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false); + var openedId = info.Id; + Func closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None); + + liveStream = new ExclusiveLiveStream(info, closeFn); + + var startTime = DateTime.UtcNow; + await liveStream.Open(cancellationToken).ConfigureAwait(false); + var endTime = DateTime.UtcNow; + _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds); + } + + info.RequiresClosing = true; + + var idPrefix = service.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_"; + + info.LiveStreamId = idPrefix + info.Id; + + Normalize(info, service, isVideo); + + return new Tuple(info, liveStream); + } + + public async Task> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken) + { + var baseItem = (LiveTvChannel)item; + var service = GetService(baseItem); + + var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false); + + if (sources.Count == 0) + { + throw new NotImplementedException(); + } + + foreach (var source in sources) + { + Normalize(source, service, baseItem.ChannelType == ChannelType.TV); + } + + return sources; + } + + private ILiveTvService GetService(LiveTvChannel item) + { + var name = item.ServiceName; + return GetService(name); + } + + private ILiveTvService GetService(LiveTvProgram item) + { + var channel = _libraryManager.GetItemById(item.ChannelId) as LiveTvChannel; + + return GetService(channel); + } + + private ILiveTvService GetService(string name) + => Array.Find(_services, x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? throw new KeyNotFoundException( + string.Format( + CultureInfo.InvariantCulture, + "No service with the name '{0}' can be found.", + name)); + + private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo) + { + // Not all of the plugins are setting this + mediaSource.IsInfiniteStream = true; + + if (mediaSource.MediaStreams.Count == 0) + { + if (isVideo) + { + mediaSource.MediaStreams = new MediaStream[] + { + new MediaStream + { + Type = MediaStreamType.Video, + // Set the index to -1 because we don't know the exact index of the video stream within the container + Index = -1, + + // Set to true if unknown to enable deinterlacing + IsInterlaced = true + }, + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1 + } + }; + } + else + { + mediaSource.MediaStreams = new MediaStream[] + { + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1 + } + }; + } + } + + // Clean some bad data coming from providers + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.BitRate.HasValue && stream.BitRate <= 0) + { + stream.BitRate = null; + } + + if (stream.Channels.HasValue && stream.Channels <= 0) + { + stream.Channels = null; + } + + if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0) + { + stream.AverageFrameRate = null; + } + + if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0) + { + stream.RealFrameRate = null; + } + + if (stream.Width.HasValue && stream.Width <= 0) + { + stream.Width = null; + } + + if (stream.Height.HasValue && stream.Height <= 0) + { + stream.Height = null; + } + + if (stream.SampleRate.HasValue && stream.SampleRate <= 0) + { + stream.SampleRate = null; + } + + if (stream.Level.HasValue && stream.Level <= 0) + { + stream.Level = null; + } + } + + var indexes = mediaSource.MediaStreams.Select(i => i.Index).Distinct().ToList(); + + // If there are duplicate stream indexes, set them all to unknown + if (indexes.Count != mediaSource.MediaStreams.Count) + { + foreach (var stream in mediaSource.MediaStreams) + { + stream.Index = -1; + } + } + + // Set the total bitrate if not already supplied + mediaSource.InferTotalBitrate(); + + if (service is not EmbyTV.EmbyTV) + { + // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says + // mediaSource.SupportsDirectPlay = false; + // mediaSource.SupportsDirectStream = false; + mediaSource.SupportsTranscoding = true; + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize)) + { + stream.NalLengthSize = "0"; + } + + if (stream.Type == MediaStreamType.Video) + { + stream.IsInterlaced = true; + } + } + } + } + + private async Task GetChannelAsync(ChannelInfo channelInfo, string serviceName, BaseItem parentFolder, CancellationToken cancellationToken) + { + var parentFolderId = parentFolder.Id; + var isNew = false; + var forceUpdate = false; + + var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id); + + var item = _libraryManager.GetItemById(id) as LiveTvChannel; + + if (item is null) + { + item = new LiveTvChannel + { + Name = channelInfo.Name, + Id = id, + DateCreated = DateTime.UtcNow + }; + + isNew = true; + } + + if (channelInfo.Tags is not null) + { + if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase)) + { + isNew = true; + } + + item.Tags = channelInfo.Tags; + } + + if (!item.ParentId.Equals(parentFolderId)) + { + isNew = true; + } + + item.ParentId = parentFolderId; + + item.ChannelType = channelInfo.ChannelType; + item.ServiceName = serviceName; + + if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase)) + { + forceUpdate = true; + } + + item.SetProviderId(ExternalServiceTag, serviceName); + + if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.ExternalId = channelInfo.Id; + + if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.Number = channelInfo.Number; + + if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.Name = channelInfo.Name; + + if (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); + forceUpdate = true; + } + else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); + forceUpdate = true; + } + } + + if (isNew) + { + _libraryManager.CreateItem(item, parentFolder); + } + else if (forceUpdate) + { + await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + + return item; + } + + private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(ProgramInfo info, Dictionary allExistingPrograms, LiveTvChannel channel) + { + var id = _tvDtoService.GetInternalProgramId(info.Id); + + var isNew = false; + var forceUpdate = false; + + if (!allExistingPrograms.TryGetValue(id, out LiveTvProgram item)) + { + isNew = true; + item = new LiveTvProgram + { + Name = info.Name, + Id = id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + if (!string.IsNullOrEmpty(info.Etag)) + { + item.SetProviderId(EtagKey, info.Etag); + } + } + + if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) + { + item.ShowId = info.ShowId; + forceUpdate = true; + } + + var seriesId = info.SeriesId; + + if (!item.ParentId.Equals(channel.Id)) + { + forceUpdate = true; + } + + item.ParentId = channel.Id; + + item.Audio = info.Audio; + item.ChannelId = channel.Id; + item.CommunityRating ??= info.CommunityRating; + if ((item.CommunityRating ?? 0).Equals(0)) + { + item.CommunityRating = null; + } + + item.EpisodeTitle = info.EpisodeTitle; + item.ExternalId = info.Id; + + if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.ExternalSeriesId = seriesId; + + var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); + + if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle)) + { + item.SeriesName = info.Name; + } + + var tags = new List(); + if (info.IsLive) + { + tags.Add("Live"); + } + + if (info.IsPremiere) + { + tags.Add("Premiere"); + } + + if (info.IsNews) + { + tags.Add("News"); + } + + if (info.IsSports) + { + tags.Add("Sports"); + } + + if (info.IsKids) + { + tags.Add("Kids"); + } + + if (info.IsRepeat) + { + tags.Add("Repeat"); + } + + if (info.IsMovie) + { + tags.Add("Movie"); + } + + if (isSeries) + { + tags.Add("Series"); + } + + item.Tags = tags.ToArray(); + + item.Genres = info.Genres.ToArray(); + + if (info.IsHD ?? false) + { + item.Width = 1280; + item.Height = 720; + } + + item.IsMovie = info.IsMovie; + item.IsRepeat = info.IsRepeat; + + if (item.IsSeries != isSeries) + { + forceUpdate = true; + } + + item.IsSeries = isSeries; + + item.Name = info.Name; + item.OfficialRating ??= info.OfficialRating; + item.Overview ??= info.Overview; + item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; + item.ProviderIds = info.ProviderIds; + + foreach (var providerId in info.SeriesProviderIds) + { + info.ProviderIds["Series" + providerId.Key] = providerId.Value; + } + + if (item.StartDate != info.StartDate) + { + forceUpdate = true; + } + + item.StartDate = info.StartDate; + + if (item.EndDate != info.EndDate) + { + forceUpdate = true; + } + + item.EndDate = info.EndDate; + + item.ProductionYear = info.ProductionYear; + + if (!isSeries || info.IsRepeat) + { + item.PremiereDate = info.OriginalAirDate; + } + + item.IndexNumber = info.EpisodeNumber; + item.ParentIndexNumber = info.SeasonNumber; + + if (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(info.ImagePath)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ImagePath, + Type = ImageType.Primary + }, + 0); + } + else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ImageUrl, + Type = ImageType.Primary + }, + 0); + } + } + + if (!item.HasImage(ImageType.Thumb)) + { + if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ThumbImageUrl, + Type = ImageType.Thumb + }, + 0); + } + } + + if (!item.HasImage(ImageType.Logo)) + { + if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.LogoImageUrl, + Type = ImageType.Logo + }, + 0); + } + } + + if (!item.HasImage(ImageType.Backdrop)) + { + if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.BackdropImageUrl, + Type = ImageType.Backdrop + }, + 0); + } + } + + var isUpdated = false; + if (isNew) + { + } + else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) + { + isUpdated = true; + } + else + { + var etag = info.Etag; + + if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) + { + item.SetProviderId(EtagKey, etag); + isUpdated = true; + } + } + + if (isNew || isUpdated) + { + item.OnMetadataChanged(); + } + + return (item, isNew, isUpdated); + } + + public async Task GetProgram(string id, CancellationToken cancellationToken, User user = null) + { + var program = _libraryManager.GetItemById(id); + + var dto = _dtoService.GetBaseItemDto(program, new DtoOptions(), user); + + var list = new List<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)> + { + (dto, program.ExternalId, program.ExternalSeriesId) + }; + + await AddRecordingInfo(list, cancellationToken).ConfigureAwait(false); + + return dto; + } + + public async Task> GetPrograms(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) + { + var user = query.User; + + var topFolder = GetInternalLiveTvFolder(cancellationToken); + + if (query.OrderBy.Count == 0) + { + // Unless something else was specified, order by start date to take advantage of a specialized index + query.OrderBy = new[] + { + (ItemSortBy.StartDate, SortOrder.Ascending) + }; + } + + RemoveFields(options); + + var internalQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + MinEndDate = query.MinEndDate, + MinStartDate = query.MinStartDate, + MaxEndDate = query.MaxEndDate, + MaxStartDate = query.MaxStartDate, + ChannelIds = query.ChannelIds, + IsMovie = query.IsMovie, + IsSeries = query.IsSeries, + IsSports = query.IsSports, + IsKids = query.IsKids, + IsNews = query.IsNews, + Genres = query.Genres, + GenreIds = query.GenreIds, + StartIndex = query.StartIndex, + Limit = query.Limit, + OrderBy = query.OrderBy, + EnableTotalRecordCount = query.EnableTotalRecordCount, + TopParentIds = new[] { topFolder.Id }, + Name = query.Name, + DtoOptions = options, + HasAired = query.HasAired, + IsAiring = query.IsAiring + }; + + if (!string.IsNullOrWhiteSpace(query.SeriesTimerId)) + { + var seriesTimers = await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false); + var seriesTimer = seriesTimers.Items.FirstOrDefault(i => string.Equals(_tvDtoService.GetInternalSeriesTimerId(i.Id).ToString("N", CultureInfo.InvariantCulture), query.SeriesTimerId, StringComparison.OrdinalIgnoreCase)); + if (seriesTimer is not null) + { + internalQuery.ExternalSeriesId = seriesTimer.SeriesId; + + if (string.IsNullOrWhiteSpace(seriesTimer.SeriesId)) + { + // Better to return nothing than every program in the database + return new QueryResult(); + } + } + else + { + // Better to return nothing than every program in the database + return new QueryResult(); + } + } + + var queryResult = _libraryManager.QueryItems(internalQuery); + + var returnArray = _dtoService.GetBaseItemDtos(queryResult.Items, options, user); + + return new QueryResult( + query.StartIndex, + queryResult.TotalRecordCount, + returnArray); + } + + public QueryResult GetRecommendedProgramsInternal(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) + { + var user = query.User; + + var topFolder = GetInternalLiveTvFolder(cancellationToken); + + var internalQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + IsAiring = query.IsAiring, + HasAired = query.HasAired, + IsNews = query.IsNews, + IsMovie = query.IsMovie, + IsSeries = query.IsSeries, + IsSports = query.IsSports, + IsKids = query.IsKids, + EnableTotalRecordCount = query.EnableTotalRecordCount, + OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) }, + TopParentIds = new[] { topFolder.Id }, + DtoOptions = options, + GenreIds = query.GenreIds + }; + + if (query.Limit.HasValue) + { + internalQuery.Limit = Math.Max(query.Limit.Value * 4, 200); + } + + var programList = _libraryManager.QueryItems(internalQuery).Items; + var totalCount = programList.Count; + + var orderedPrograms = programList.Cast().OrderBy(i => i.StartDate.Date); + + if (query.IsAiring ?? false) + { + orderedPrograms = orderedPrograms + .ThenByDescending(i => GetRecommendationScore(i, user, true)); + } + + IEnumerable programs = orderedPrograms; + + if (query.Limit.HasValue) + { + programs = programs.Take(query.Limit.Value); + } + + return new QueryResult( + query.StartIndex, + totalCount, + programs.ToArray()); + } + + public Task> GetRecommendedProgramsAsync(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) + { + if (!(query.IsAiring ?? false)) + { + return GetPrograms(query, options, cancellationToken); + } + + RemoveFields(options); + + var internalResult = GetRecommendedProgramsInternal(query, options, cancellationToken); + + return Task.FromResult(new QueryResult( + query.StartIndex, + internalResult.TotalRecordCount, + _dtoService.GetBaseItemDtos(internalResult.Items, options, query.User))); + } + + private int GetRecommendationScore(LiveTvProgram program, User user, bool factorChannelWatchCount) + { + var score = 0; + + if (program.IsLive) + { + score++; + } + + if (program.IsSeries && !program.IsRepeat) + { + score++; + } + + var channel = _libraryManager.GetItemById(program.ChannelId); + + if (channel is null) + { + return score; + } + + var channelUserdata = _userDataManager.GetUserData(user, channel); + + if (channelUserdata.Likes.HasValue) + { + score += channelUserdata.Likes.Value ? 2 : -2; + } + + if (channelUserdata.IsFavorite) + { + score += 3; + } + + if (factorChannelWatchCount) + { + score += channelUserdata.PlayCount; + } + + return score; + } + + private async Task AddRecordingInfo(IEnumerable<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)> programs, CancellationToken cancellationToken) + { + IReadOnlyList timerList = null; + IReadOnlyList seriesTimerList = null; + + foreach (var programTuple in programs) + { + var program = programTuple.ItemDto; + var externalProgramId = programTuple.ExternalId; + string externalSeriesId = programTuple.ExternalSeriesId; + + timerList ??= (await GetTimersInternal(new TimerQuery(), cancellationToken).ConfigureAwait(false)).Items; + + var timer = timerList.FirstOrDefault(i => string.Equals(i.ProgramId, externalProgramId, StringComparison.OrdinalIgnoreCase)); + var foundSeriesTimer = false; + + if (timer is not null) + { + if (timer.Status != RecordingStatus.Cancelled && timer.Status != RecordingStatus.Error) + { + program.TimerId = _tvDtoService.GetInternalTimerId(timer.Id); + + program.Status = timer.Status.ToString(); + } + + if (!string.IsNullOrEmpty(timer.SeriesTimerId)) + { + program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(timer.SeriesTimerId) + .ToString("N", CultureInfo.InvariantCulture); + + foundSeriesTimer = true; + } + } + + if (foundSeriesTimer || string.IsNullOrWhiteSpace(externalSeriesId)) + { + continue; + } + + seriesTimerList ??= (await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false)).Items; + + var seriesTimer = seriesTimerList.FirstOrDefault(i => string.Equals(i.SeriesId, externalSeriesId, StringComparison.OrdinalIgnoreCase)); + + if (seriesTimer is not null) + { + program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(seriesTimer.Id) + .ToString("N", CultureInfo.InvariantCulture); + } + } + } + + internal Task RefreshChannels(IProgress progress, CancellationToken cancellationToken) + { + return RefreshChannelsInternal(progress, cancellationToken); + } + + private async Task RefreshChannelsInternal(IProgress progress, CancellationToken cancellationToken) + { + await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); + + await EmbyTV.EmbyTV.Current.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); + + var numComplete = 0; + double progressPerService = _services.Length == 0 + ? 0 + : 1.0 / _services.Length; + + var newChannelIdList = new List(); + var newProgramIdList = new List(); + + var cleanDatabase = true; + + foreach (var service in _services) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogDebug("Refreshing guide from {Name}", service.Name); + + try + { + var innerProgress = new ActionableProgress(); + innerProgress.RegisterAction(p => progress.Report(p * progressPerService)); + + var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); + + newChannelIdList.AddRange(idList.Item1); + newProgramIdList.AddRange(idList.Item2); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + cleanDatabase = false; + _logger.LogError(ex, "Error refreshing channels for service"); + } + + numComplete++; + double percent = numComplete; + percent /= _services.Length; + + progress.Report(100 * percent); + } + + if (cleanDatabase) + { + CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { BaseItemKind.LiveTvChannel }, progress, cancellationToken); + CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { BaseItemKind.LiveTvProgram }, progress, cancellationToken); + } + + var coreService = _services.OfType().FirstOrDefault(); + + if (coreService is not null) + { + await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); + await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); + } + + // Load these now which will prefetch metadata + var dtoOptions = new DtoOptions(); + var fields = dtoOptions.Fields.ToList(); + dtoOptions.Fields = fields.ToArray(); + + progress.Report(100); + } + + private async Task, List>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress progress, CancellationToken cancellationToken) + { + progress.Report(10); + + var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) + .Select(i => new Tuple(service.Name, i)) + .ToList(); + + var list = new List(); + + var numComplete = 0; + var parentFolder = GetInternalLiveTvFolder(cancellationToken); + + foreach (var channelInfo in allChannelsList) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var item = await GetChannelAsync(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false); + + list.Add(item); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); + } + + numComplete++; + double percent = numComplete; + percent /= allChannelsList.Count; + + progress.Report((5 * percent) + 10); + } + + progress.Report(15); + + numComplete = 0; + var programs = new List(); + var channels = new List(); + + var guideDays = GetGuideDays(); + + _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); + + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var currentChannel in list) + { + channels.Add(currentChannel.Id); + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var start = DateTime.UtcNow.AddHours(-1); + var end = start.AddDays(guideDays); + + var isMovie = false; + var isSports = false; + var isNews = false; + var isKids = false; + var iSSeries = false; + + var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList(); + + var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + ChannelIds = new Guid[] { currentChannel.Id }, + DtoOptions = new DtoOptions(true) + }).Cast().ToDictionary(i => i.Id); + + var newPrograms = new List(); + var updatedPrograms = new List(); + + foreach (var program in channelPrograms) + { + var programTuple = GetProgram(program, existingPrograms, currentChannel); + var programItem = programTuple.Item; + + if (programTuple.IsNew) + { + newPrograms.Add(programItem); + } + else if (programTuple.IsUpdated) + { + updatedPrograms.Add(programItem); + } + + programs.Add(programItem.Id); + + isMovie |= program.IsMovie; + iSSeries |= program.IsSeries; + isSports |= program.IsSports; + isNews |= program.IsNews; + isKids |= program.IsKids; + } + + _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); + + if (newPrograms.Count > 0) + { + _libraryManager.CreateItems(newPrograms, null, cancellationToken); + } + + if (updatedPrograms.Count > 0) + { + await _libraryManager.UpdateItemsAsync( + updatedPrograms, + currentChannel, + ItemUpdateType.MetadataImport, + cancellationToken).ConfigureAwait(false); + } + + currentChannel.IsMovie = isMovie; + currentChannel.IsNews = isNews; + currentChannel.IsSports = isSports; + currentChannel.IsSeries = iSSeries; + + if (isKids) + { + currentChannel.AddTag("Kids"); + } + + await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + await currentChannel.RefreshMetadata( + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = true + }, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); + } + + numComplete++; + double percent = numComplete / (double)allChannelsList.Count; + + progress.Report((85 * percent) + 15); + } + + progress.Report(100); + return new Tuple, List>(channels, programs); + } + + private void CleanDatabaseInternal(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress progress, CancellationToken cancellationToken) + { + var list = _itemRepo.GetItemIdsList(new InternalItemsQuery + { + IncludeItemTypes = validTypes, + DtoOptions = new DtoOptions(false) + }); + + var numComplete = 0; + + foreach (var itemId in list) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (itemId.Equals(default)) + { + // Somehow some invalid data got into the db. It probably predates the boundary checking + continue; + } + + if (!currentIdList.Contains(itemId)) + { + var item = _libraryManager.GetItemById(itemId); + + if (item is not null) + { + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false, + DeleteFromExternalProvider = false + }, + false); + } + } + + numComplete++; + double percent = numComplete / (double)list.Count; + + progress.Report(100 * percent); + } + } + + private double GetGuideDays() + { + var config = GetConfiguration(); + + if (config.GuideDays.HasValue) + { + return Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays)); + } + + return 7; + } + + private async Task> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user) + { + if (user is null) + { + return new QueryResult(); + } + + var folders = await GetRecordingFoldersAsync(user, true).ConfigureAwait(false); + var folderIds = Array.ConvertAll(folders, x => x.Id); + + var excludeItemTypes = new List(); + + if (folderIds.Length == 0) + { + return new QueryResult(); + } + + var includeItemTypes = new List(); + var genres = new List(); + + if (query.IsMovie.HasValue) + { + if (query.IsMovie.Value) + { + includeItemTypes.Add(BaseItemKind.Movie); + } + else + { + excludeItemTypes.Add(BaseItemKind.Movie); + } + } + + if (query.IsSeries.HasValue) + { + if (query.IsSeries.Value) + { + includeItemTypes.Add(BaseItemKind.Episode); + } + else + { + excludeItemTypes.Add(BaseItemKind.Episode); + } + } + + if (query.IsSports ?? false) + { + genres.Add("Sports"); + } + + if (query.IsKids ?? false) + { + genres.Add("Kids"); + genres.Add("Children"); + genres.Add("Family"); + } + + var limit = query.Limit; + + if (query.IsInProgress ?? false) + { + // limit = (query.Limit ?? 10) * 2; + limit = null; + + // var allActivePaths = EmbyTV.EmbyTV.Current.GetAllActiveRecordings().Select(i => i.Path).ToArray(); + // var items = allActivePaths.Select(i => _libraryManager.FindByPath(i, false)).Where(i => i is not null).ToArray(); + + // return new QueryResult + // { + // Items = items, + // TotalRecordCount = items.Length + // }; + + dtoOptions.Fields = dtoOptions.Fields.Concat(new[] { ItemFields.Tags }).Distinct().ToArray(); + } + + var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) + { + MediaTypes = new[] { MediaType.Video }, + Recursive = true, + AncestorIds = folderIds, + IsFolder = false, + IsVirtualItem = false, + Limit = limit, + StartIndex = query.StartIndex, + OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, + EnableTotalRecordCount = query.EnableTotalRecordCount, + IncludeItemTypes = includeItemTypes.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray(), + Genres = genres.ToArray(), + DtoOptions = dtoOptions + }); + + if (query.IsInProgress ?? false) + { + // TODO: Fix The co-variant conversion between Video[] and BaseItem[], this can generate runtime issues. + result.Items = result + .Items + .OfType - [XmlRoot("Profile")] public class DeviceProfile { /// @@ -31,104 +26,6 @@ namespace MediaBrowser.Model.Dlna [XmlIgnore] public string? Id { get; set; } - /// - /// Gets or sets the Identification. - /// - public DeviceIdentification? Identification { get; set; } - - /// - /// Gets or sets the friendly name of the device profile, which can be shown to users. - /// - public string? FriendlyName { get; set; } - - /// - /// Gets or sets the manufacturer of the device which this profile represents. - /// - public string? Manufacturer { get; set; } - - /// - /// Gets or sets an url for the manufacturer of the device which this profile represents. - /// - public string? ManufacturerUrl { get; set; } - - /// - /// Gets or sets the model name of the device which this profile represents. - /// - public string? ModelName { get; set; } - - /// - /// Gets or sets the model description of the device which this profile represents. - /// - public string? ModelDescription { get; set; } - - /// - /// Gets or sets the model number of the device which this profile represents. - /// - public string? ModelNumber { get; set; } - - /// - /// Gets or sets the ModelUrl. - /// - public string? ModelUrl { get; set; } - - /// - /// Gets or sets the serial number of the device which this profile represents. - /// - public string? SerialNumber { get; set; } - - /// - /// Gets or sets a value indicating whether EnableAlbumArtInDidl. - /// - [DefaultValue(false)] - public bool EnableAlbumArtInDidl { get; set; } - - /// - /// Gets or sets a value indicating whether EnableSingleAlbumArtLimit. - /// - [DefaultValue(false)] - public bool EnableSingleAlbumArtLimit { get; set; } - - /// - /// Gets or sets a value indicating whether EnableSingleSubtitleLimit. - /// - [DefaultValue(false)] - public bool EnableSingleSubtitleLimit { get; set; } - - /// - /// Gets or sets the SupportedMediaTypes. - /// - public string SupportedMediaTypes { get; set; } = "Audio,Photo,Video"; - - /// - /// Gets or sets the UserId. - /// - public string? UserId { get; set; } - - /// - /// Gets or sets the AlbumArtPn. - /// - public string? AlbumArtPn { get; set; } - - /// - /// Gets or sets the MaxAlbumArtWidth. - /// - public int? MaxAlbumArtWidth { get; set; } - - /// - /// Gets or sets the MaxAlbumArtHeight. - /// - public int? MaxAlbumArtHeight { get; set; } - - /// - /// Gets or sets the maximum allowed width of embedded icons. - /// - public int? MaxIconWidth { get; set; } - - /// - /// Gets or sets the maximum allowed height of embedded icons. - /// - public int? MaxIconHeight { get; set; } - /// /// Gets or sets the maximum allowed bitrate for all streamed content. /// @@ -149,51 +46,6 @@ namespace MediaBrowser.Model.Dlna /// public int? MaxStaticMusicBitrate { get; set; } = 8000000; - /// - /// Gets or sets the content of the aggregationFlags element in the urn:schemas-sonycom:av namespace. - /// - public string? SonyAggregationFlags { get; set; } - - /// - /// Gets or sets the ProtocolInfo. - /// - public string? ProtocolInfo { get; set; } - - /// - /// Gets or sets the TimelineOffsetSeconds. - /// - [DefaultValue(0)] - public int TimelineOffsetSeconds { get; set; } - - /// - /// Gets or sets a value indicating whether RequiresPlainVideoItems. - /// - [DefaultValue(false)] - public bool RequiresPlainVideoItems { get; set; } - - /// - /// Gets or sets a value indicating whether RequiresPlainFolders. - /// - [DefaultValue(false)] - public bool RequiresPlainFolders { get; set; } - - /// - /// Gets or sets a value indicating whether EnableMSMediaReceiverRegistrar. - /// - [DefaultValue(false)] - public bool EnableMSMediaReceiverRegistrar { get; set; } - - /// - /// Gets or sets a value indicating whether IgnoreTranscodeByteRangeRequests. - /// - [DefaultValue(false)] - public bool IgnoreTranscodeByteRangeRequests { get; set; } - - /// - /// Gets or sets the XmlRootAttributes. - /// - public XmlAttribute[] XmlRootAttributes { get; set; } = Array.Empty(); - /// /// Gets or sets the direct play profiles. /// @@ -214,298 +66,9 @@ namespace MediaBrowser.Model.Dlna /// public CodecProfile[] CodecProfiles { get; set; } = Array.Empty(); - /// - /// Gets or sets the ResponseProfiles. - /// - public ResponseProfile[] ResponseProfiles { get; set; } = Array.Empty(); - /// /// Gets or sets the subtitle profiles. /// public SubtitleProfile[] SubtitleProfiles { get; set; } = Array.Empty(); - - /// - /// The GetSupportedMediaTypes. - /// - /// The . - public MediaType[] GetSupportedMediaTypes() - { - return ContainerProfile.SplitValue(SupportedMediaTypes) - .Select(m => Enum.TryParse(m, out var parsed) ? parsed : MediaType.Unknown) - .Where(m => m != MediaType.Unknown) - .ToArray(); - } - - /// - /// Gets the audio transcoding profile. - /// - /// The container. - /// The audio Codec. - /// A . - public TranscodingProfile? GetAudioTranscodingProfile(string? container, string? audioCodec) - { - container = (container ?? string.Empty).TrimStart('.'); - - foreach (var i in TranscodingProfiles) - { - if (i.Type != DlnaProfileType.Audio) - { - continue; - } - - if (!string.Equals(container, i.Container, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!i.GetAudioCodecs().Contains(audioCodec ?? string.Empty, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - return i; - } - - return null; - } - - /// - /// Gets the video transcoding profile. - /// - /// The container. - /// The audio Codec. - /// The video Codec. - /// The . - public TranscodingProfile? GetVideoTranscodingProfile(string? container, string? audioCodec, string? videoCodec) - { - container = (container ?? string.Empty).TrimStart('.'); - - foreach (var i in TranscodingProfiles) - { - if (i.Type != DlnaProfileType.Video) - { - continue; - } - - if (!string.Equals(container, i.Container, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!i.GetAudioCodecs().Contains(audioCodec ?? string.Empty, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!string.Equals(videoCodec, i.VideoCodec, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - return i; - } - - return null; - } - - /// - /// Gets the audio media profile. - /// - /// The container. - /// The audio codec. - /// The audio channels. - /// The audio bitrate. - /// The audio sample rate. - /// The audio bit depth. - /// The . - public ResponseProfile? GetAudioMediaProfile(string? container, string? audioCodec, int? audioChannels, int? audioBitrate, int? audioSampleRate, int? audioBitDepth) - { - foreach (var i in ResponseProfiles) - { - if (i.Type != DlnaProfileType.Audio) - { - continue; - } - - if (!ContainerProfile.ContainsContainer(i.GetContainers(), container)) - { - continue; - } - - var audioCodecs = i.GetAudioCodecs(); - if (audioCodecs.Length > 0 && !audioCodecs.Contains(audioCodec ?? string.Empty, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var anyOff = false; - foreach (ProfileCondition c in i.Conditions) - { - if (!ConditionProcessor.IsAudioConditionSatisfied(GetModelProfileCondition(c), audioChannels, audioBitrate, audioSampleRate, audioBitDepth)) - { - anyOff = true; - break; - } - } - - if (anyOff) - { - continue; - } - - return i; - } - - return null; - } - - /// - /// Gets the model profile condition. - /// - /// The c. - /// The . - private ProfileCondition GetModelProfileCondition(ProfileCondition c) - { - return new ProfileCondition - { - Condition = c.Condition, - IsRequired = c.IsRequired, - Property = c.Property, - Value = c.Value - }; - } - - /// - /// Gets the image media profile. - /// - /// The container. - /// The width. - /// The height. - /// The . - public ResponseProfile? GetImageMediaProfile(string container, int? width, int? height) - { - foreach (var i in ResponseProfiles) - { - if (i.Type != DlnaProfileType.Photo) - { - continue; - } - - if (!ContainerProfile.ContainsContainer(i.GetContainers(), container)) - { - continue; - } - - var anyOff = false; - foreach (var c in i.Conditions) - { - if (!ConditionProcessor.IsImageConditionSatisfied(GetModelProfileCondition(c), width, height)) - { - anyOff = true; - break; - } - } - - if (anyOff) - { - continue; - } - - return i; - } - - return null; - } - - /// - /// Gets the video media profile. - /// - /// The container. - /// The audio codec. - /// The video codec. - /// The width. - /// The height. - /// The bit depth. - /// The video bitrate. - /// The video profile. - /// The video range type. - /// The video level. - /// The video framerate. - /// The packet length. - /// The timestamp. - /// True if anamorphic. - /// True if interlaced. - /// The ref frames. - /// The number of video streams. - /// The number of audio streams. - /// The video Codec tag. - /// True if Avc. - /// The . - public ResponseProfile? GetVideoMediaProfile( - string? container, - string? audioCodec, - string? videoCodec, - int? width, - int? height, - int? bitDepth, - int? videoBitrate, - string? videoProfile, - VideoRangeType videoRangeType, - double? videoLevel, - float? videoFramerate, - int? packetLength, - TransportStreamTimestamp timestamp, - bool? isAnamorphic, - bool? isInterlaced, - int? refFrames, - int? numVideoStreams, - int? numAudioStreams, - string? videoCodecTag, - bool? isAvc) - { - foreach (var i in ResponseProfiles) - { - if (i.Type != DlnaProfileType.Video) - { - continue; - } - - if (!ContainerProfile.ContainsContainer(i.GetContainers(), container)) - { - continue; - } - - var audioCodecs = i.GetAudioCodecs(); - if (audioCodecs.Length > 0 && !audioCodecs.Contains(audioCodec ?? string.Empty, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var videoCodecs = i.GetVideoCodecs(); - if (videoCodecs.Length > 0 && !videoCodecs.Contains(videoCodec ?? string.Empty, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var anyOff = false; - foreach (ProfileCondition c in i.Conditions) - { - if (!ConditionProcessor.IsVideoConditionSatisfied(GetModelProfileCondition(c), width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numVideoStreams, numAudioStreams, videoCodecTag, isAvc)) - { - anyOff = true; - break; - } - } - - if (anyOff) - { - continue; - } - - return i; - } - - return null; - } } } diff --git a/MediaBrowser.Model/Dlna/DeviceProfileInfo.cs b/MediaBrowser.Model/Dlna/DeviceProfileInfo.cs deleted file mode 100644 index 74c32c523e..0000000000 --- a/MediaBrowser.Model/Dlna/DeviceProfileInfo.cs +++ /dev/null @@ -1,26 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Dlna -{ - public class DeviceProfileInfo - { - /// - /// Gets or sets the identifier. - /// - /// The identifier. - public string Id { get; set; } - - /// - /// Gets or sets the name. - /// - /// The name. - public string Name { get; set; } - - /// - /// Gets or sets the type. - /// - /// The type. - public DeviceProfileType Type { get; set; } - } -} diff --git a/MediaBrowser.Model/Dlna/DeviceProfileType.cs b/MediaBrowser.Model/Dlna/DeviceProfileType.cs deleted file mode 100644 index 46062abd09..0000000000 --- a/MediaBrowser.Model/Dlna/DeviceProfileType.cs +++ /dev/null @@ -1,10 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Dlna -{ - public enum DeviceProfileType - { - System = 0, - User = 1 - } -} diff --git a/MediaBrowser.Model/Dlna/HeaderMatchType.cs b/MediaBrowser.Model/Dlna/HeaderMatchType.cs deleted file mode 100644 index 2a9abb20eb..0000000000 --- a/MediaBrowser.Model/Dlna/HeaderMatchType.cs +++ /dev/null @@ -1,11 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Dlna -{ - public enum HeaderMatchType - { - Equals = 0, - Regex = 1, - Substring = 2 - } -} diff --git a/MediaBrowser.Model/Dlna/HttpHeaderInfo.cs b/MediaBrowser.Model/Dlna/HttpHeaderInfo.cs deleted file mode 100644 index 17c4dffcc0..0000000000 --- a/MediaBrowser.Model/Dlna/HttpHeaderInfo.cs +++ /dev/null @@ -1,19 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System.Xml.Serialization; - -namespace MediaBrowser.Model.Dlna -{ - public class HttpHeaderInfo - { - [XmlAttribute("name")] - public string Name { get; set; } - - [XmlAttribute("value")] - public string Value { get; set; } - - [XmlAttribute("match")] - public HeaderMatchType Match { get; set; } - } -} diff --git a/MediaBrowser.Model/Dlna/ResponseProfile.cs b/MediaBrowser.Model/Dlna/ResponseProfile.cs deleted file mode 100644 index bf9661f7f3..0000000000 --- a/MediaBrowser.Model/Dlna/ResponseProfile.cs +++ /dev/null @@ -1,51 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; -using System.Xml.Serialization; - -namespace MediaBrowser.Model.Dlna -{ - public class ResponseProfile - { - public ResponseProfile() - { - Conditions = Array.Empty(); - } - - [XmlAttribute("container")] - public string Container { get; set; } - - [XmlAttribute("audioCodec")] - public string AudioCodec { get; set; } - - [XmlAttribute("videoCodec")] - public string VideoCodec { get; set; } - - [XmlAttribute("type")] - public DlnaProfileType Type { get; set; } - - [XmlAttribute("orgPn")] - public string OrgPn { get; set; } - - [XmlAttribute("mimeType")] - public string MimeType { get; set; } - - public ProfileCondition[] Conditions { get; set; } - - public string[] GetContainers() - { - return ContainerProfile.SplitValue(Container); - } - - public string[] GetAudioCodecs() - { - return ContainerProfile.SplitValue(AudioCodec); - } - - public string[] GetVideoCodecs() - { - return ContainerProfile.SplitValue(VideoCodec); - } - } -} diff --git a/MediaBrowser.Model/Dlna/XmlAttribute.cs b/MediaBrowser.Model/Dlna/XmlAttribute.cs deleted file mode 100644 index 03bb2e4b11..0000000000 --- a/MediaBrowser.Model/Dlna/XmlAttribute.cs +++ /dev/null @@ -1,25 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System.Xml.Serialization; - -namespace MediaBrowser.Model.Dlna -{ - /// - /// Defines the . - /// - public class XmlAttribute - { - /// - /// Gets or sets the name of the attribute. - /// - [XmlAttribute("name")] - public string Name { get; set; } - - /// - /// Gets or sets the value of the attribute. - /// - [XmlAttribute("value")] - public string Value { get; set; } - } -} -- cgit v1.2.3 From a9e0fb0322491ee71e6f8f09b11f8f68bd58019e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 12 Jan 2024 20:17:54 +0000 Subject: chore(deps): update actions/upload-artifact action to v4.1.0 --- .github/workflows/ci-openapi.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 693d28a48f..75ec82c5f1 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -25,7 +25,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # v4.0.0 + uses: actions/upload-artifact@1eb3cb2b3e0f29609092a73eb033bb759a334595 # v4.1.0 with: name: openapi-head retention-days: 14 @@ -59,7 +59,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # v4.0.0 + uses: actions/upload-artifact@1eb3cb2b3e0f29609092a73eb033bb759a334595 # v4.1.0 with: name: openapi-base retention-days: 14 -- cgit v1.2.3 From ad51f4f95d09f8ed64fed6013a47f87c01fcca5f Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Fri, 12 Jan 2024 18:37:14 -0500 Subject: Add LiveTv configuration extension --- .../Configuration/LiveTvConfigurationExtensions.cs | 18 ++++++++++++++ src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 28 ++++++++++------------ src/Jellyfin.LiveTv/LiveTvManager.cs | 25 ++++++++----------- src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs | 9 ++----- src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs | 9 ++----- .../TunerHosts/HdHomerun/HdHomerunHost.cs | 3 ++- 6 files changed, 46 insertions(+), 46 deletions(-) create mode 100644 src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs diff --git a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs new file mode 100644 index 0000000000..67d0e5295b --- /dev/null +++ b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs @@ -0,0 +1,18 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.LiveTv; + +namespace Jellyfin.LiveTv.Configuration; + +/// +/// extensions for Live TV. +/// +public static class LiveTvConfigurationExtensions +{ + /// + /// Gets the . + /// + /// The . + /// The . + public static LiveTvOptions GetLiveTvConfiguration(this IConfigurationManager configurationManager) + => configurationManager.GetConfiguration("livetv"); +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index 439ed965b0..532e1c897d 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -17,6 +17,7 @@ using System.Xml; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; +using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; @@ -126,7 +127,7 @@ namespace Jellyfin.LiveTv.EmbyTV { get { - var path = GetConfiguration().RecordingPath; + var path = _config.GetLiveTvConfiguration().RecordingPath; return string.IsNullOrWhiteSpace(path) ? DefaultRecordingPath @@ -189,7 +190,7 @@ namespace Jellyfin.LiveTv.EmbyTV pathsAdded.AddRange(pathsToCreate); } - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); var pathsToRemove = config.MediaLocationsCreated .Except(recordingFolders.SelectMany(i => i.Locations)) @@ -831,7 +832,7 @@ namespace Jellyfin.LiveTv.EmbyTV public Task GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) { - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); var defaults = new SeriesTimerInfo() { @@ -932,7 +933,7 @@ namespace Jellyfin.LiveTv.EmbyTV private List> GetListingProviders() { - return GetConfiguration().ListingProviders + return _config.GetLiveTvConfiguration().ListingProviders .Select(i => { var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); @@ -1076,7 +1077,7 @@ namespace Jellyfin.LiveTv.EmbyTV private string GetRecordingPath(TimerInfo timer, RemoteSearchResult metadata, out string seriesPath) { var recordPath = RecordingPath; - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); seriesPath = null; if (timer.IsProgramSeries) @@ -1596,7 +1597,7 @@ namespace Jellyfin.LiveTv.EmbyTV private void PostProcessRecording(TimerInfo timer, string path) { - var options = GetConfiguration(); + var options = _config.GetLiveTvConfiguration(); if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor)) { return; @@ -1777,7 +1778,7 @@ namespace Jellyfin.LiveTv.EmbyTV program.AddGenre("News"); } - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); if (config.SaveRecordingNFO) { @@ -2128,11 +2129,6 @@ namespace Jellyfin.LiveTv.EmbyTV return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); } - private LiveTvOptions GetConfiguration() - { - return _config.GetConfiguration("livetv"); - } - private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer) { if (timer.IsManual) @@ -2519,7 +2515,7 @@ namespace Jellyfin.LiveTv.EmbyTV }; } - var customPath = GetConfiguration().MovieRecordingPath; + var customPath = _config.GetLiveTvConfiguration().MovieRecordingPath; if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) { yield return new VirtualFolderInfo @@ -2530,7 +2526,7 @@ namespace Jellyfin.LiveTv.EmbyTV }; } - customPath = GetConfiguration().SeriesRecordingPath; + customPath = _config.GetLiveTvConfiguration().SeriesRecordingPath; if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) { yield return new VirtualFolderInfo @@ -2546,7 +2542,7 @@ namespace Jellyfin.LiveTv.EmbyTV { var list = new List(); - var configuredDeviceIds = GetConfiguration().TunerHosts + var configuredDeviceIds = _config.GetLiveTvConfiguration().TunerHosts .Where(i => !string.IsNullOrWhiteSpace(i.DeviceId)) .Select(i => i.DeviceId) .ToList(); @@ -2579,7 +2575,7 @@ namespace Jellyfin.LiveTv.EmbyTV { var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); - var configuredDevices = GetConfiguration().TunerHosts + var configuredDevices = _config.GetLiveTvConfiguration().TunerHosts .Where(i => string.Equals(i.Type, host.Type, StringComparison.OrdinalIgnoreCase)) .ToList(); diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 4fc9956538..0b3d35731a 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -12,7 +12,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; -using MediaBrowser.Common.Configuration; +using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; @@ -108,11 +108,6 @@ namespace Jellyfin.LiveTv public IReadOnlyList ListingProviders => _listingProviders; - private LiveTvOptions GetConfiguration() - { - return _config.GetConfiguration("livetv"); - } - public string GetEmbyTvActiveRecordingPath(string id) { return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); @@ -1302,7 +1297,7 @@ namespace Jellyfin.LiveTv private double GetGuideDays() { - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); if (config.GuideDays.HasValue) { @@ -2125,7 +2120,7 @@ namespace Jellyfin.LiveTv private bool IsLiveTvEnabled(User user) { - return user.HasPermission(PermissionKind.EnableLiveTvAccess) && (Services.Count > 1 || GetConfiguration().TunerHosts.Length > 0); + return user.HasPermission(PermissionKind.EnableLiveTvAccess) && (Services.Count > 1 || _config.GetLiveTvConfiguration().TunerHosts.Length > 0); } public IEnumerable GetEnabledUsers() @@ -2187,7 +2182,7 @@ namespace Jellyfin.LiveTv await configurable.Validate(info).ConfigureAwait(false); } - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); var list = config.TunerHosts.ToList(); var index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); @@ -2232,7 +2227,7 @@ namespace Jellyfin.LiveTv await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false); - LiveTvOptions config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); var list = config.ListingProviders.ToList(); int index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); @@ -2257,7 +2252,7 @@ namespace Jellyfin.LiveTv public void DeleteListingsProvider(string id) { - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); @@ -2267,7 +2262,7 @@ namespace Jellyfin.LiveTv public async Task SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber) { - var config = GetConfiguration(); + 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(); @@ -2327,7 +2322,7 @@ namespace Jellyfin.LiveTv public Task> GetLineups(string providerType, string providerId, string country, string location) { - var config = GetConfiguration(); + var config = _config.GetLiveTvConfiguration(); if (string.IsNullOrWhiteSpace(providerId)) { @@ -2357,13 +2352,13 @@ namespace Jellyfin.LiveTv public Task> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken) { - var info = GetConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); + var info = _config.GetLiveTvConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); return EmbyTV.EmbyTV.Current.GetChannelsForListingsProvider(info, cancellationToken); } public Task> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken) { - var info = GetConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); + 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); } diff --git a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs index e58296a70a..18bd61d999 100644 --- a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs +++ b/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Tasks; namespace Jellyfin.LiveTv @@ -38,7 +38,7 @@ namespace Jellyfin.LiveTv public string Category => "Live TV"; /// - public bool IsHidden => _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Length == 0; + public bool IsHidden => _liveTvManager.Services.Count == 1 && _config.GetLiveTvConfiguration().TunerHosts.Length == 0; /// public bool IsEnabled => true; @@ -66,10 +66,5 @@ namespace Jellyfin.LiveTv new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } }; } - - private LiveTvOptions GetConfiguration() - { - return _config.GetConfiguration("livetv"); - } } } diff --git a/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs index 769f196bdf..afc2e4f9ce 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs @@ -10,7 +10,7 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; +using Jellyfin.LiveTv.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; @@ -69,7 +69,7 @@ namespace Jellyfin.LiveTv.TunerHosts protected virtual IList GetTunerHosts() { - return GetConfiguration().TunerHosts + return Config.GetLiveTvConfiguration().TunerHosts .Where(i => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase)) .ToList(); } @@ -228,10 +228,5 @@ namespace Jellyfin.LiveTv.TunerHosts return channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase); } - - protected LiveTvOptions GetConfiguration() - { - return Config.GetConfiguration("livetv"); - } } } diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index b1b08e992b..a56af65a6f 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -16,6 +16,7 @@ using System.Threading.Tasks; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; +using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller; @@ -278,7 +279,7 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun { var list = new List(); - foreach (var host in GetConfiguration().TunerHosts + foreach (var host in Config.GetLiveTvConfiguration().TunerHosts .Where(i => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase))) { try -- cgit v1.2.3 From 063168fc1c5942e1e386eaa6f13755192a0527c4 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Fri, 12 Jan 2024 18:38:23 -0500 Subject: Move LiveTvConfigurationFactory to Configuration folder --- .../Configuration/LiveTvConfigurationFactory.cs | 24 +++++++++++++++++++++ src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs | 25 ---------------------- 2 files changed, 24 insertions(+), 25 deletions(-) create mode 100644 src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationFactory.cs delete mode 100644 src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs diff --git a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationFactory.cs b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationFactory.cs new file mode 100644 index 0000000000..258afbb056 --- /dev/null +++ b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationFactory.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.LiveTv; + +namespace Jellyfin.LiveTv.Configuration; + +/// +/// implementation for . +/// +public class LiveTvConfigurationFactory : IConfigurationFactory +{ + /// + public IEnumerable GetConfigurations() + { + return new[] + { + new ConfigurationStore + { + ConfigurationType = typeof(LiveTvOptions), + Key = "livetv" + } + }; + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs b/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs deleted file mode 100644 index ddbf6345c5..0000000000 --- a/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.LiveTv; - -namespace Jellyfin.LiveTv -{ - /// - /// implementation for . - /// - public class LiveTvConfigurationFactory : IConfigurationFactory - { - /// - public IEnumerable GetConfigurations() - { - return new ConfigurationStore[] - { - new ConfigurationStore - { - ConfigurationType = typeof(LiveTvOptions), - Key = "livetv" - } - }; - } - } -} -- cgit v1.2.3 From 449365182cb710e5d0d18b1d599a53f76b814dd8 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Fri, 12 Jan 2024 19:07:44 -0500 Subject: Move LiveTV service registration to extension method --- Jellyfin.Server/CoreAppHost.cs | 9 -------- Jellyfin.Server/Startup.cs | 2 ++ .../LiveTvServiceCollectionExtensions.cs | 25 ++++++++++++++++++++++ 3 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 5192b9e21b..d5b6e93b8e 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -7,7 +7,6 @@ using Jellyfin.Api.WebSocketListeners; using Jellyfin.Drawing; using Jellyfin.Drawing.Skia; using Jellyfin.LiveTv; -using Jellyfin.LiveTv.Channels; using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Activity; using Jellyfin.Server.Implementations.Devices; @@ -18,18 +17,15 @@ using Jellyfin.Server.Implementations.Users; using MediaBrowser.Controller; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.BaseItemManager; -using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Activity; -using MediaBrowser.Model.IO; using MediaBrowser.Providers.Lyric; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -101,11 +97,6 @@ namespace Jellyfin.Server serviceCollection.AddScoped(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - foreach (var type in GetExportTypes()) { serviceCollection.AddSingleton(typeof(ILyricProvider), type); diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 1030c6f5fc..7d5f22545d 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -5,6 +5,7 @@ using System.Net.Http.Headers; using System.Net.Mime; using System.Text; using Jellyfin.Api.Middleware; +using Jellyfin.LiveTv.Extensions; using Jellyfin.MediaEncoding.Hls.Extensions; using Jellyfin.Networking; using Jellyfin.Networking.HappyEyeballs; @@ -121,6 +122,7 @@ namespace Jellyfin.Server .AddCheck>(nameof(JellyfinDbContext)); services.AddHlsPlaylistGenerator(); + services.AddLiveTvServices(); services.AddHostedService(); } diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs new file mode 100644 index 0000000000..5865e88af0 --- /dev/null +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using Jellyfin.LiveTv.Channels; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.LiveTv.Extensions; + +/// +/// Live TV extensions for . +/// +public static class LiveTvServiceCollectionExtensions +{ + /// + /// Adds Live TV services to the . + /// + /// The to add services to. + public static void AddLiveTvServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } +} -- cgit v1.2.3 From 9c2c066e6f62fb713d5bad0fcf5a0b3dcf58e6e1 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Fri, 12 Jan 2024 20:45:05 -0500 Subject: Add ITunerHostManager service --- Emby.Server.Implementations/ApplicationHost.cs | 2 +- Jellyfin.Api/Controllers/LiveTvController.cs | 19 +-- MediaBrowser.Controller/LiveTv/ILiveTvManager.cs | 15 +- .../LiveTv/ITunerHostManager.cs | 47 ++++++ src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 89 +--------- .../LiveTvServiceCollectionExtensions.cs | 6 + src/Jellyfin.LiveTv/LiveTvManager.cs | 76 +-------- src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs | 181 +++++++++++++++++++++ 8 files changed, 258 insertions(+), 177 deletions(-) create mode 100644 MediaBrowser.Controller/LiveTv/ITunerHostManager.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 48d5d8c6a9..5870fed761 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -695,7 +695,7 @@ namespace Emby.Server.Implementations GetExports(), GetExports()); - Resolve().AddParts(GetExports(), GetExports(), GetExports()); + Resolve().AddParts(GetExports(), GetExports()); Resolve().AddParts(GetExports()); } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 5502836239..1a2a3caae8 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -10,7 +10,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -43,6 +42,7 @@ namespace Jellyfin.Api.Controllers; public class LiveTvController : BaseJellyfinApiController { private readonly ILiveTvManager _liveTvManager; + private readonly ITunerHostManager _tunerHostManager; private readonly IUserManager _userManager; private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; @@ -55,6 +55,7 @@ public class LiveTvController : BaseJellyfinApiController /// Initializes a new instance of the class. /// /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. @@ -64,6 +65,7 @@ public class LiveTvController : BaseJellyfinApiController /// Instance of the interface. public LiveTvController( ILiveTvManager liveTvManager, + ITunerHostManager tunerHostManager, IUserManager userManager, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, @@ -73,6 +75,7 @@ public class LiveTvController : BaseJellyfinApiController ITranscodeManager transcodeManager) { _liveTvManager = liveTvManager; + _tunerHostManager = tunerHostManager; _userManager = userManager; _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; @@ -951,9 +954,7 @@ public class LiveTvController : BaseJellyfinApiController [Authorize(Policy = Policies.LiveTvManagement)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo) - { - return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false); - } + => await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false); /// /// Deletes a tuner host. @@ -1130,10 +1131,8 @@ public class LiveTvController : BaseJellyfinApiController [HttpGet("TunerHosts/Types")] [Authorize(Policy = Policies.LiveTvAccess)] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetTunerHostTypes() - { - return _liveTvManager.GetTunerHostTypes(); - } + public IEnumerable GetTunerHostTypes() + => _tunerHostManager.GetTunerHostTypes(); /// /// Discover tuners. @@ -1146,9 +1145,7 @@ public class LiveTvController : BaseJellyfinApiController [Authorize(Policy = Policies.LiveTvManagement)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task>> DiscoverTuners([FromQuery] bool newDevicesOnly = false) - { - return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false); - } + => await _tunerHostManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false); /// /// Gets a live tv recording stream. diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 4206159e7b..26f9fe42d3 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -71,9 +71,8 @@ namespace MediaBrowser.Controller.LiveTv /// Adds the parts. /// /// The services. - /// The tuner hosts. /// The listing providers. - void AddParts(IEnumerable services, IEnumerable tunerHosts, IEnumerable listingProviders); + void AddParts(IEnumerable services, IEnumerable listingProviders); /// /// Gets the timer. @@ -253,14 +252,6 @@ namespace MediaBrowser.Controller.LiveTv /// Task. Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem Item, BaseItemDto ItemDto)> programs, IReadOnlyList fields, User user = null); - /// - /// Saves the tuner host. - /// - /// Turner host to save. - /// Option to specify that data source has changed. - /// Tuner host information wrapped in a task. - Task SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true); - /// /// Saves the listing provider. /// @@ -298,10 +289,6 @@ namespace MediaBrowser.Controller.LiveTv Task> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken); - List GetTunerHostTypes(); - - Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken); - string GetEmbyTvActiveRecordingPath(string id); ActiveRecordingInfo GetActiveRecordingInfo(string path); diff --git a/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs b/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs new file mode 100644 index 0000000000..7e4caaf136 --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; + +namespace MediaBrowser.Controller.LiveTv; + +/// +/// Service responsible for managing the s. +/// +public interface ITunerHostManager +{ + /// + /// Gets the available s. + /// + IReadOnlyList TunerHosts { get; } + + /// + /// Gets the s for the available s. + /// + /// The s. + IEnumerable GetTunerHostTypes(); + + /// + /// Saves the tuner host. + /// + /// Turner host to save. + /// Option to specify that data source has changed. + /// Tuner host information wrapped in a task. + Task SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true); + + /// + /// Discovers the available tuners. + /// + /// A value indicating whether to only return new devices. + /// The to use. + /// The s. + Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken); + + /// + /// Scans for tuner devices that have changed URLs. + /// + /// The to use. + /// A task that represents the scanning operation. + Task ScanForTunerDeviceChanges(CancellationToken cancellationToken); +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index 532e1c897d..625451fa37 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -44,8 +44,6 @@ namespace Jellyfin.LiveTv.EmbyTV { public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; - private const int TunerDiscoveryDurationMs = 3000; - private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; @@ -54,6 +52,7 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly TimerManager _timerProvider; private readonly LiveTvManager _liveTvManager; + private readonly ITunerHostManager _tunerHostManager; private readonly IFileSystem _fileSystem; private readonly ILibraryMonitor _libraryMonitor; @@ -80,6 +79,7 @@ namespace Jellyfin.LiveTv.EmbyTV IHttpClientFactory httpClientFactory, IServerConfigurationManager config, ILiveTvManager liveTvManager, + ITunerHostManager tunerHostManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, @@ -97,6 +97,7 @@ namespace Jellyfin.LiveTv.EmbyTV _providerManager = providerManager; _mediaEncoder = mediaEncoder; _liveTvManager = (LiveTvManager)liveTvManager; + _tunerHostManager = tunerHostManager; _mediaSourceManager = mediaSourceManager; _streamHelper = streamHelper; @@ -310,7 +311,7 @@ namespace Jellyfin.LiveTv.EmbyTV { var list = new List(); - foreach (var hostInstance in _liveTvManager.TunerHosts) + foreach (var hostInstance in _tunerHostManager.TunerHosts) { try { @@ -510,7 +511,7 @@ namespace Jellyfin.LiveTv.EmbyTV { var list = new List(); - foreach (var hostInstance in _liveTvManager.TunerHosts) + foreach (var hostInstance in _tunerHostManager.TunerHosts) { try { @@ -966,7 +967,7 @@ namespace Jellyfin.LiveTv.EmbyTV return result; } - foreach (var hostInstance in _liveTvManager.TunerHosts) + foreach (var hostInstance in _tunerHostManager.TunerHosts) { try { @@ -998,7 +999,7 @@ namespace Jellyfin.LiveTv.EmbyTV throw new ArgumentNullException(nameof(channelId)); } - foreach (var hostInstance in _liveTvManager.TunerHosts) + foreach (var hostInstance in _tunerHostManager.TunerHosts) { try { @@ -2537,81 +2538,5 @@ namespace Jellyfin.LiveTv.EmbyTV }; } } - - public async Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) - { - var list = new List(); - - var configuredDeviceIds = _config.GetLiveTvConfiguration().TunerHosts - .Where(i => !string.IsNullOrWhiteSpace(i.DeviceId)) - .Select(i => i.DeviceId) - .ToList(); - - foreach (var host in _liveTvManager.TunerHosts) - { - var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); - - if (newDevicesOnly) - { - discoveredDevices = discoveredDevices.Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - - list.AddRange(discoveredDevices); - } - - return list; - } - - public async Task ScanForTunerDeviceChanges(CancellationToken cancellationToken) - { - foreach (var host in _liveTvManager.TunerHosts) - { - await ScanForTunerDeviceChanges(host, cancellationToken).ConfigureAwait(false); - } - } - - private async Task ScanForTunerDeviceChanges(ITunerHost host, CancellationToken cancellationToken) - { - var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); - - var configuredDevices = _config.GetLiveTvConfiguration().TunerHosts - .Where(i => string.Equals(i.Type, host.Type, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - foreach (var device in discoveredDevices) - { - var configuredDevice = configuredDevices.FirstOrDefault(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase)); - - if (configuredDevice is not null && !string.Equals(device.Url, configuredDevice.Url, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Tuner url has changed from {PreviousUrl} to {NewUrl}", configuredDevice.Url, device.Url); - - configuredDevice.Url = device.Url; - await _liveTvManager.SaveTunerHost(configuredDevice).ConfigureAwait(false); - } - } - } - - private async Task> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) - { - try - { - var discoveredDevices = await host.DiscoverDevices(discoveryDurationMs, cancellationToken).ConfigureAwait(false); - - foreach (var device in discoveredDevices) - { - _logger.LogInformation("Discovered tuner device {0} at {1}", host.Name, device.Url); - } - - return discoveredDevices; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error discovering tuner devices"); - - return new List(); - } - } } } diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index 5865e88af0..5490547ec3 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ using Jellyfin.LiveTv.Channels; +using Jellyfin.LiveTv.TunerHosts; +using Jellyfin.LiveTv.TunerHosts.HdHomerun; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.IO; @@ -21,5 +23,9 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 0b3d35731a..71822f3762 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -57,9 +57,9 @@ namespace Jellyfin.LiveTv private readonly IFileSystem _fileSystem; private readonly IChannelManager _channelManager; private readonly LiveTvDtoService _tvDtoService; + private readonly ITunerHostManager _tunerHostManager; private ILiveTvService[] _services = Array.Empty(); - private ITunerHost[] _tunerHosts = Array.Empty(); private IListingsProvider[] _listingProviders = Array.Empty(); public LiveTvManager( @@ -74,7 +74,8 @@ namespace Jellyfin.LiveTv ILocalizationManager localization, IFileSystem fileSystem, IChannelManager channelManager, - LiveTvDtoService liveTvDtoService) + LiveTvDtoService liveTvDtoService, + ITunerHostManager tunerHostManager) { _config = config; _logger = logger; @@ -88,6 +89,7 @@ namespace Jellyfin.LiveTv _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; + _tunerHostManager = tunerHostManager; } public event EventHandler> SeriesTimerCancelled; @@ -104,8 +106,6 @@ namespace Jellyfin.LiveTv /// The services. public IReadOnlyList Services => _services; - public IReadOnlyList TunerHosts => _tunerHosts; - public IReadOnlyList ListingProviders => _listingProviders; public string GetEmbyTvActiveRecordingPath(string id) @@ -113,16 +113,10 @@ namespace Jellyfin.LiveTv return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); } - /// - /// Adds the parts. - /// - /// The services. - /// The tuner hosts. - /// The listing providers. - public void AddParts(IEnumerable services, IEnumerable tunerHosts, IEnumerable listingProviders) + /// + public void AddParts(IEnumerable services, IEnumerable listingProviders) { _services = services.ToArray(); - _tunerHosts = tunerHosts.Where(i => i.IsSupported).ToArray(); _listingProviders = listingProviders.ToArray(); @@ -154,20 +148,6 @@ namespace Jellyfin.LiveTv })); } - public List GetTunerHostTypes() - { - return _tunerHosts.OrderBy(i => i.Name).Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Type - }).ToList(); - } - - public Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) - { - return EmbyTV.EmbyTV.Current.DiscoverTuners(newDevicesOnly, cancellationToken); - } - public QueryResult GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken) { var user = query.UserId.Equals(default) @@ -1029,7 +1009,7 @@ namespace Jellyfin.LiveTv { await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); - await EmbyTV.EmbyTV.Current.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); + await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); var numComplete = 0; double progressPerService = _services.Length == 0 @@ -2166,48 +2146,6 @@ namespace Jellyfin.LiveTv return _libraryManager.GetNamedView(name, CollectionType.livetv, name); } - public async Task SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true) - { - info = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(info)); - - var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); - - if (provider is null) - { - throw new ResourceNotFoundException(); - } - - if (provider is IConfigurableTunerHost configurable) - { - await configurable.Validate(info).ConfigureAwait(false); - } - - var config = _config.GetLiveTvConfiguration(); - - var list = config.TunerHosts.ToList(); - var 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.TunerHosts = list.ToArray(); - } - else - { - config.TunerHosts[index] = info; - } - - _config.SaveConfiguration("livetv", config); - - if (dataSourceChanged) - { - _taskManager.CancelIfRunningAndQueue(); - } - - return info; - } - public async Task SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings) { // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs new file mode 100644 index 0000000000..04eb8293a1 --- /dev/null +++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs @@ -0,0 +1,181 @@ +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.Extensions; +using Jellyfin.LiveTv.Configuration; +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.TunerHosts; + +/// +public class TunerHostManager : ITunerHostManager +{ + private const int TunerDiscoveryDurationMs = 3000; + + private readonly ILogger _logger; + private readonly IConfigurationManager _config; + private readonly ITaskManager _taskManager; + private readonly ITunerHost[] _tunerHosts; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + public TunerHostManager( + ILogger logger, + IConfigurationManager config, + ITaskManager taskManager, + IEnumerable tunerHosts) + { + _logger = logger; + _config = config; + _taskManager = taskManager; + _tunerHosts = tunerHosts.Where(t => t.IsSupported).ToArray(); + } + + /// + public IReadOnlyList TunerHosts => _tunerHosts; + + /// + public IEnumerable GetTunerHostTypes() + => _tunerHosts.OrderBy(i => i.Name).Select(i => new NameIdPair + { + Name = i.Name, + Id = i.Type + }); + + /// + public async Task SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true) + { + info = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(info))!; + + var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + + if (provider is null) + { + throw new ResourceNotFoundException(); + } + + if (provider is IConfigurableTunerHost configurable) + { + await configurable.Validate(info).ConfigureAwait(false); + } + + var config = _config.GetLiveTvConfiguration(); + + var list = config.TunerHosts.ToList(); + var 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.TunerHosts = list.ToArray(); + } + else + { + config.TunerHosts[index] = info; + } + + _config.SaveConfiguration("livetv", config); + + if (dataSourceChanged) + { + _taskManager.CancelIfRunningAndQueue(); + } + + return info; + } + + /// + public async Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) + { + var list = new List(); + + var configuredDeviceIds = _config.GetLiveTvConfiguration().TunerHosts + .Where(i => !string.IsNullOrWhiteSpace(i.DeviceId)) + .Select(i => i.DeviceId) + .ToList(); + + foreach (var host in _tunerHosts) + { + var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); + + if (newDevicesOnly) + { + discoveredDevices = discoveredDevices + .Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + list.AddRange(discoveredDevices); + } + + return list; + } + + /// + public async Task ScanForTunerDeviceChanges(CancellationToken cancellationToken) + { + foreach (var host in _tunerHosts) + { + await ScanForTunerDeviceChanges(host, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ScanForTunerDeviceChanges(ITunerHost host, CancellationToken cancellationToken) + { + var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); + + var configuredDevices = _config.GetLiveTvConfiguration().TunerHosts + .Where(i => string.Equals(i.Type, host.Type, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var device in discoveredDevices) + { + var configuredDevice = configuredDevices.FirstOrDefault(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase)); + + if (configuredDevice is not null && !string.Equals(device.Url, configuredDevice.Url, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Tuner url has changed from {PreviousUrl} to {NewUrl}", configuredDevice.Url, device.Url); + + configuredDevice.Url = device.Url; + await SaveTunerHost(configuredDevice).ConfigureAwait(false); + } + } + } + + private async Task> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) + { + try + { + var discoveredDevices = await host.DiscoverDevices(discoveryDurationMs, cancellationToken).ConfigureAwait(false); + + foreach (var device in discoveredDevices) + { + _logger.LogInformation("Discovered tuner device {0} at {1}", host.Name, device.Url); + } + + return discoveredDevices; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error discovering tuner devices"); + + return new List(); + } + } +} -- cgit v1.2.3 From d40224128c031e38054090e063247b9a06e46f6d Mon Sep 17 00:00:00 2001 From: SuperDumbTM Date: Fri, 12 Jan 2024 08:37:06 +0000 Subject: Translated using Weblate (Chinese (Traditional, Hong Kong)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/zh_Hant_HK/ --- .../Localization/Core/zh-HK.json | 78 +++++++++++----------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index e8b8c2c5fa..66cbee3948 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -1,15 +1,15 @@ { "Albums": "專輯", - "AppDeviceValues": "程式: {0}, 設備: {1}", + "AppDeviceValues": "程式:{0},設備:{1}", "Application": "應用程式", "Artists": "藝人", - "AuthenticationSucceededWithUserName": "{0} 授權成功", + "AuthenticationSucceededWithUserName": "成功授權 {0}", "Books": "書籍", "CameraImageUploadedFrom": "{0} 成功上傳一張新照片", "Channels": "頻道", "ChapterNameValue": "第 {0} 章", "Collections": "系列", - "DeviceOfflineWithName": "{0} 已斷開連接", + "DeviceOfflineWithName": "{0} 已中斷連接", "DeviceOnlineWithName": "{0} 已連接", "FailedLoginAttemptWithUserName": "{0} 登入失敗", "Favorites": "我的最愛", @@ -27,15 +27,15 @@ "HeaderRecordingGroups": "錄製組", "HomeVideos": "家庭影片", "Inherit": "繼承", - "ItemAddedWithName": "{0} 已被添加至媒體庫", + "ItemAddedWithName": "{0} 已被加入至媒體庫", "ItemRemovedWithName": "{0} 已從媒體庫移除", - "LabelIpAddressValue": "IP 地址: {0}", - "LabelRunningTimeValue": "運行時間: {0}", + "LabelIpAddressValue": "IP 地址:{0}", + "LabelRunningTimeValue": "運作時間:{0}", "Latest": "最新", "MessageApplicationUpdated": "Jellyfin 已被更新", "MessageApplicationUpdatedTo": "Jellyfin 已被更新至 {0}", "MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定 {0} 已被更新", - "MessageServerConfigurationUpdated": "伺服器設定已經被更新", + "MessageServerConfigurationUpdated": "已更新伺服器設定", "MixedContent": "混合內容", "Movies": "電影", "Music": "音樂", @@ -43,23 +43,23 @@ "NameInstallFailed": "{0} 安裝失敗", "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知的季度", - "NewVersionIsAvailable": "有較新版本的 Jellyfin 可供下載。", + "NewVersionIsAvailable": "有新版本的 Jellyfin 可供下載。", "NotificationOptionApplicationUpdateAvailable": "有可用的更新", - "NotificationOptionApplicationUpdateInstalled": "應用程式已被更新", - "NotificationOptionAudioPlayback": "開始播放音訊", + "NotificationOptionApplicationUpdateInstalled": "完成更新應用程式", + "NotificationOptionAudioPlayback": "播放音訊", "NotificationOptionAudioPlaybackStopped": "停止播放音訊", - "NotificationOptionCameraImageUploaded": "相片已被上傳", + "NotificationOptionCameraImageUploaded": "相片上傳", "NotificationOptionInstallationFailed": "安裝失敗", - "NotificationOptionNewLibraryContent": "已添加新内容", - "NotificationOptionPluginError": "插件出現錯誤", - "NotificationOptionPluginInstalled": "插件已被安裝", - "NotificationOptionPluginUninstalled": "插件已被移除", - "NotificationOptionPluginUpdateInstalled": "插件已被更新", + "NotificationOptionNewLibraryContent": "新增媒體", + "NotificationOptionPluginError": "插件錯誤", + "NotificationOptionPluginInstalled": "安裝插件", + "NotificationOptionPluginUninstalled": "解除安裝插件", + "NotificationOptionPluginUpdateInstalled": "完成更新插件", "NotificationOptionServerRestartRequired": "伺服器需要重啟", - "NotificationOptionTaskFailed": "排程任務執行失敗", - "NotificationOptionUserLockedOut": "用戶已被鎖定", - "NotificationOptionVideoPlayback": "開始播放影片", - "NotificationOptionVideoPlaybackStopped": "已停止播放影片", + "NotificationOptionTaskFailed": "排程工作執行失敗", + "NotificationOptionUserLockedOut": "封鎖用戶", + "NotificationOptionVideoPlayback": "播放影片", + "NotificationOptionVideoPlaybackStopped": "停止播放影片", "Photos": "相片", "Playlists": "播放清單", "Plugin": "插件", @@ -68,7 +68,7 @@ "PluginUpdatedWithName": "已更新 {0}", "ProviderValue": "提供者:{0}", "ScheduledTaskFailedWithName": "{0} 執行失敗", - "ScheduledTaskStartedWithName": "{0} 開始執行", + "ScheduledTaskStartedWithName": "開始執行 {0}", "ServerNameNeedsToBeRestarted": "{0} 需要重啟", "Shows": "節目", "Songs": "歌曲", @@ -79,50 +79,52 @@ "System": "系統", "TvShows": "電視節目", "User": "用戶", - "UserCreatedWithName": "用戶 {0} 已被建立", + "UserCreatedWithName": "建立新用戶 {0}", "UserDeletedWithName": "用戶 {0} 已被移除", "UserDownloadingItemWithValues": "{0} 正在下載 {1}", - "UserLockedOutWithName": "使用者 {0} 已被鎖定", + "UserLockedOutWithName": "用戶 {0} 已被封鎖", "UserOfflineFromDevice": "{0} 從 {1} 斷開連接", "UserOnlineFromDevice": "{0} 從 {1} 連線", "UserPasswordChangedWithName": "{0} 的密碼已被變改", - "UserPolicyUpdatedWithName": "使用者協議已更新為 {0}", - "UserStartedPlayingItemWithValues": "{0} 正在 {2} 上播放 {1}", + "UserPolicyUpdatedWithName": "使用條款已更新為 {0}", + "UserStartedPlayingItemWithValues": "{0} 在 {2} 上播放 {1}", "UserStoppedPlayingItemWithValues": "{0} 已停止在 {2} 上播放 {1}", "ValueHasBeenAddedToLibrary": "已添加 {0} 到你的媒體庫", "ValueSpecialEpisodeName": "特典 - {0}", "VersionNumber": "版本 {0}", - "TaskDownloadMissingSubtitles": "下載缺少的字幕", + "TaskDownloadMissingSubtitles": "下載欠缺字幕", "TaskUpdatePlugins": "更新插件", "TasksApplicationCategory": "應用程式", - "TaskRefreshLibraryDescription": "掃描媒體庫以加入新增檔案及重新載入 metadata。", + "TaskRefreshLibraryDescription": "掃描媒體庫以加入新增的檔案及重新載入元數據。", "TasksMaintenanceCategory": "維護", - "TaskDownloadMissingSubtitlesDescription": "根據元數據中的設定,在互聯網上搜索缺少的字幕。", + "TaskDownloadMissingSubtitlesDescription": "根據元數據中的設定,在網上搜尋欠缺的字幕。", "TaskRefreshChannelsDescription": "重新載入網絡頻道的資訊。", "TaskRefreshChannels": "重新載入頻道", - "TaskCleanTranscodeDescription": "刪除超過一天的轉碼文件。", - "TaskCleanTranscode": "清理轉碼目錄", + "TaskCleanTranscodeDescription": "刪除超過一天的轉碼檔案。", + "TaskCleanTranscode": "清理轉碼檔資料夾", "TaskUpdatePluginsDescription": "下載並更新能夠被自動更新的插件。", - "TaskRefreshPeopleDescription": "更新媒體庫中演員和導演的元數據。", + "TaskRefreshPeopleDescription": "更新你的媒體中有關的演員和導演的元數據。", "TaskCleanLogsDescription": "刪除超過{0}天的紀錄檔。", - "TaskCleanLogs": "清理紀錄檔目錄", + "TaskCleanLogs": "清理紀錄檔資料夾", "TaskRefreshLibrary": "掃描媒體庫", "TaskRefreshChapterImagesDescription": "為帶有章節的影片建立縮圖。", "TaskRefreshChapterImages": "提取章節圖像", "TaskCleanCacheDescription": "刪除系統不再需要的緩存文件。", - "TaskCleanCache": "清理緩存目錄", + "TaskCleanCache": "清理緩存資料夾", "TasksChannelsCategory": "網絡頻道", - "TasksLibraryCategory": "庫", + "TasksLibraryCategory": "媒體庫", "TaskRefreshPeople": "重新載入人物", "TaskCleanActivityLog": "清理活動記錄", "Undefined": "未定義", "Forced": "強制", "Default": "預設", - "TaskOptimizeDatabaseDescription": "壓縮數據庫並截斷可用空間。在掃描媒體庫或執行其他數據庫的修改後運行此任務可能會提高性能。", + "TaskOptimizeDatabaseDescription": "壓縮數據庫及釋放可用空間。完成任何會修改數據庫的工作(例如掃描媒體庫)後,執行此工作或可提升伺服器速度。", "TaskOptimizeDatabase": "最佳化數據庫", "TaskCleanActivityLogDescription": "刪除早於設定時間的活動記錄。", - "TaskKeyframeExtractorDescription": "提取關鍵幀以建立更準確的 HLS 播放列表。此工作或需要使用較長時間來完成。", - "TaskKeyframeExtractor": "關鍵幀提取器", + "TaskKeyframeExtractorDescription": "提取關鍵影格(Keyframe)以建立更準確的 HLS playlist。此工作可能需要使用較長時間來完成。", + "TaskKeyframeExtractor": "關鍵影格提取器", "External": "外部", - "HearingImpaired": "聽力障礙" + "HearingImpaired": "聽力障礙", + "TaskRefreshTrickplayImages": "建立 Trickplay 圖像", + "TaskRefreshTrickplayImagesDescription": "為已啟用 Trickplay 的媒體庫內的影片建立 Trickplay 預覽圖。" } -- cgit v1.2.3 From 3ce16713dd013b5aabdedebafd025f2224d2475f Mon Sep 17 00:00:00 2001 From: JPVenson Date: Sun, 14 Jan 2024 16:50:09 +0100 Subject: Fixed disposable not being called (#10613) * Fixed disposable not being called * PulledUp usage of IAsyncDisposable for sessioninfo Co-authored-by: Patrick Barron --- .../Session/SessionManager.cs | 12 +++++----- MediaBrowser.Controller/Session/ISessionManager.cs | 3 ++- MediaBrowser.Controller/Session/SessionInfo.cs | 28 ++++++---------------- 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 6f599e4c7c..f457a78b3b 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -189,7 +189,7 @@ namespace Emby.Server.Implementations.Session _logger); } - private void OnSessionEnded(SessionInfo info) + private async ValueTask OnSessionEnded(SessionInfo info) { EventHelper.QueueEventIfNotNull( SessionEnded, @@ -202,7 +202,7 @@ namespace Emby.Server.Implementations.Session _eventManager.Publish(new SessionEndedEventArgs(info)); - info.Dispose(); + await info.DisposeAsync().ConfigureAwait(false); } /// @@ -301,12 +301,12 @@ namespace Emby.Server.Implementations.Session await _mediaSourceManager.CloseLiveStream(session.PlayState.LiveStreamId).ConfigureAwait(false); } - OnSessionEnded(session); + await OnSessionEnded(session).ConfigureAwait(false); } } /// - public void ReportSessionEnded(string sessionId) + public async ValueTask ReportSessionEnded(string sessionId) { CheckDisposed(); var session = GetSession(sessionId, false); @@ -317,7 +317,7 @@ namespace Emby.Server.Implementations.Session _activeConnections.TryRemove(key, out _); - OnSessionEnded(session); + await OnSessionEnded(session).ConfigureAwait(false); } } @@ -1590,7 +1590,7 @@ namespace Emby.Server.Implementations.Session { try { - ReportSessionEnded(session.Id); + await ReportSessionEnded(session.Id).ConfigureAwait(false); } catch (Exception ex) { diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 53df7133b5..5a47236f92 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -111,7 +111,8 @@ namespace MediaBrowser.Controller.Session /// Reports the session ended. /// /// The session identifier. - void ReportSessionEnded(string sessionId); + /// Task. + ValueTask ReportSessionEnded(string sessionId); /// /// Sends the general command. diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs index 3e30c8dc45..3a12a56f1e 100644 --- a/MediaBrowser.Controller/Session/SessionInfo.cs +++ b/MediaBrowser.Controller/Session/SessionInfo.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Controller.Session /// /// Class SessionInfo. /// - public sealed class SessionInfo : IAsyncDisposable, IDisposable + public sealed class SessionInfo : IAsyncDisposable { // 1 second private const long ProgressIncrement = 10000000; @@ -374,26 +374,6 @@ namespace MediaBrowser.Controller.Session } } - /// - public void Dispose() - { - _disposed = true; - - StopAutomaticProgress(); - - var controllers = SessionControllers.ToList(); - SessionControllers = Array.Empty(); - - foreach (var controller in controllers) - { - if (controller is IDisposable disposable) - { - _logger.LogDebug("Disposing session controller synchronously {TypeName}", disposable.GetType().Name); - disposable.Dispose(); - } - } - } - public async ValueTask DisposeAsync() { _disposed = true; @@ -401,6 +381,7 @@ namespace MediaBrowser.Controller.Session StopAutomaticProgress(); var controllers = SessionControllers.ToList(); + SessionControllers = Array.Empty(); foreach (var controller in controllers) { @@ -409,6 +390,11 @@ namespace MediaBrowser.Controller.Session _logger.LogDebug("Disposing session controller asynchronously {TypeName}", disposableAsync.GetType().Name); await disposableAsync.DisposeAsync().ConfigureAwait(false); } + else if (controller is IDisposable disposable) + { + _logger.LogDebug("Disposing session controller synchronously {TypeName}", disposable.GetType().Name); + disposable.Dispose(); + } } } } -- cgit v1.2.3 From c23a038ba8f275e061c148ea27e458174a9a7cbe Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Fri, 12 Jan 2024 20:56:58 -0500 Subject: Remove unnecessary allocations in TunerHostManager --- Jellyfin.Api/Controllers/LiveTvController.cs | 4 ++-- .../LiveTv/ITunerHostManager.cs | 3 +-- src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs | 25 ++++++++-------------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 1a2a3caae8..27eb88b60f 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -1144,8 +1144,8 @@ public class LiveTvController : BaseJellyfinApiController [HttpGet("Tuners/Discover")] [Authorize(Policy = Policies.LiveTvManagement)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> DiscoverTuners([FromQuery] bool newDevicesOnly = false) - => await _tunerHostManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false); + public IAsyncEnumerable DiscoverTuners([FromQuery] bool newDevicesOnly = false) + => _tunerHostManager.DiscoverTuners(newDevicesOnly); /// /// Gets a live tv recording stream. diff --git a/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs b/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs index 7e4caaf136..3df6066f66 100644 --- a/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs +++ b/MediaBrowser.Controller/LiveTv/ITunerHostManager.cs @@ -34,9 +34,8 @@ public interface ITunerHostManager /// Discovers the available tuners. /// /// A value indicating whether to only return new devices. - /// The to use. /// The s. - Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken); + IAsyncEnumerable DiscoverTuners(bool newDevicesOnly); /// /// Scans for tuner devices that have changed URLs. diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs index 04eb8293a1..3e4b0e13fc 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -101,10 +100,8 @@ public class TunerHostManager : ITunerHostManager } /// - public async Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) + public async IAsyncEnumerable DiscoverTuners(bool newDevicesOnly) { - var list = new List(); - var configuredDeviceIds = _config.GetLiveTvConfiguration().TunerHosts .Where(i => !string.IsNullOrWhiteSpace(i.DeviceId)) .Select(i => i.DeviceId) @@ -112,19 +109,15 @@ public class TunerHostManager : ITunerHostManager foreach (var host in _tunerHosts) { - var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); - - if (newDevicesOnly) + var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, CancellationToken.None).ConfigureAwait(false); + foreach (var tuner in discoveredDevices) { - discoveredDevices = discoveredDevices - .Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparison.OrdinalIgnoreCase)) - .ToList(); + if (!newDevicesOnly || !configuredDeviceIds.Contains(tuner.DeviceId, StringComparer.OrdinalIgnoreCase)) + { + yield return tuner; + } } - - list.AddRange(discoveredDevices); } - - return list; } /// @@ -158,7 +151,7 @@ public class TunerHostManager : ITunerHostManager } } - private async Task> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) + private async Task> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) { try { @@ -175,7 +168,7 @@ public class TunerHostManager : ITunerHostManager { _logger.LogError(ex, "Error discovering tuner devices"); - return new List(); + return Array.Empty(); } } } -- cgit v1.2.3 From c101d287f24cf53bce0674bf70d88ae61da67ed9 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Fri, 12 Jan 2024 21:16:08 -0500 Subject: Remove unused Live TV code --- .../Channels/IChannelManager.cs | 7 - MediaBrowser.Controller/LiveTv/ILiveTvService.cs | 13 -- MediaBrowser.Controller/LiveTv/ITunerHost.cs | 7 - .../LiveTv/LiveTvServiceStatusInfo.cs | 54 ------ MediaBrowser.Controller/LiveTv/LiveTvTunerInfo.cs | 77 -------- MediaBrowser.Controller/LiveTv/RecordingInfo.cs | 210 --------------------- .../LiveTv/RecordingStatusChangedEventArgs.cs | 16 -- MediaBrowser.Model/IO/IStreamHelper.cs | 2 - MediaBrowser.Model/LiveTv/LiveTvTunerStatus.cs | 12 -- src/Jellyfin.LiveTv/Channels/ChannelManager.cs | 34 ---- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 5 - src/Jellyfin.LiveTv/StreamHelper.cs | 30 --- .../TunerHosts/HdHomerun/HdHomerunHost.cs | 184 +----------------- src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs | 16 -- 14 files changed, 10 insertions(+), 657 deletions(-) delete mode 100644 MediaBrowser.Controller/LiveTv/LiveTvServiceStatusInfo.cs delete mode 100644 MediaBrowser.Controller/LiveTv/LiveTvTunerInfo.cs delete mode 100644 MediaBrowser.Controller/LiveTv/RecordingInfo.cs delete mode 100644 MediaBrowser.Controller/LiveTv/RecordingStatusChangedEventArgs.cs delete mode 100644 MediaBrowser.Model/LiveTv/LiveTvTunerStatus.cs diff --git a/MediaBrowser.Controller/Channels/IChannelManager.cs b/MediaBrowser.Controller/Channels/IChannelManager.cs index 8eb27888ab..c8b432ecb2 100644 --- a/MediaBrowser.Controller/Channels/IChannelManager.cs +++ b/MediaBrowser.Controller/Channels/IChannelManager.cs @@ -95,12 +95,5 @@ namespace MediaBrowser.Controller.Channels /// The cancellation token. /// The item media sources. IEnumerable GetStaticMediaSources(BaseItem item, CancellationToken cancellationToken); - - /// - /// Whether the item supports media probe. - /// - /// The item. - /// Whether media probe should be enabled. - bool EnableMediaProbe(BaseItem item); } } diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvService.cs b/MediaBrowser.Controller/LiveTv/ILiveTvService.cs index ce34954e3f..52fb156481 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvService.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvService.cs @@ -140,14 +140,6 @@ namespace MediaBrowser.Controller.LiveTv /// Task. Task CloseLiveStream(string id, CancellationToken cancellationToken); - /// - /// Records the live stream. - /// - /// The identifier. - /// The cancellation token. - /// Task. - Task RecordLiveStream(string id, CancellationToken cancellationToken); - /// /// Resets the tuner. /// @@ -180,9 +172,4 @@ namespace MediaBrowser.Controller.LiveTv { Task GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List currentLiveStreams, CancellationToken cancellationToken); } - - public interface ISupportsUpdatingDefaults - { - Task UpdateTimerDefaults(SeriesTimerInfo info, CancellationToken cancellationToken); - } } diff --git a/MediaBrowser.Controller/LiveTv/ITunerHost.cs b/MediaBrowser.Controller/LiveTv/ITunerHost.cs index b983091588..3689a2adf6 100644 --- a/MediaBrowser.Controller/LiveTv/ITunerHost.cs +++ b/MediaBrowser.Controller/LiveTv/ITunerHost.cs @@ -35,13 +35,6 @@ namespace MediaBrowser.Controller.LiveTv /// Task<IEnumerable<ChannelInfo>>. Task> GetChannels(bool enableCache, CancellationToken cancellationToken); - /// - /// Gets the tuner infos. - /// - /// The cancellation token. - /// Task<List<LiveTvTunerInfo>>. - Task> GetTunerInfos(CancellationToken cancellationToken); - /// /// Gets the channel stream. /// diff --git a/MediaBrowser.Controller/LiveTv/LiveTvServiceStatusInfo.cs b/MediaBrowser.Controller/LiveTv/LiveTvServiceStatusInfo.cs deleted file mode 100644 index eb3babc180..0000000000 --- a/MediaBrowser.Controller/LiveTv/LiveTvServiceStatusInfo.cs +++ /dev/null @@ -1,54 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System.Collections.Generic; -using MediaBrowser.Model.LiveTv; - -namespace MediaBrowser.Controller.LiveTv -{ - public class LiveTvServiceStatusInfo - { - public LiveTvServiceStatusInfo() - { - Tuners = new List(); - IsVisible = true; - } - - /// - /// Gets or sets the status. - /// - /// The status. - public LiveTvServiceStatus Status { get; set; } - - /// - /// Gets or sets the status message. - /// - /// The status message. - public string StatusMessage { get; set; } - - /// - /// Gets or sets the version. - /// - /// The version. - public string Version { get; set; } - - /// - /// Gets or sets a value indicating whether this instance has update available. - /// - /// true if this instance has update available; otherwise, false. - public bool HasUpdateAvailable { get; set; } - - /// - /// Gets or sets the tuners. - /// - /// The tuners. - public List Tuners { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is visible. - /// - /// true if this instance is visible; otherwise, false. - public bool IsVisible { get; set; } - } -} diff --git a/MediaBrowser.Controller/LiveTv/LiveTvTunerInfo.cs b/MediaBrowser.Controller/LiveTv/LiveTvTunerInfo.cs deleted file mode 100644 index aa5eb59d16..0000000000 --- a/MediaBrowser.Controller/LiveTv/LiveTvTunerInfo.cs +++ /dev/null @@ -1,77 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System.Collections.Generic; -using MediaBrowser.Model.LiveTv; - -namespace MediaBrowser.Controller.LiveTv -{ - public class LiveTvTunerInfo - { - public LiveTvTunerInfo() - { - Clients = new List(); - } - - /// - /// Gets or sets the type of the source. - /// - /// The type of the source. - public string SourceType { get; set; } - - /// - /// Gets or sets the name. - /// - /// The name. - public string Name { get; set; } - - /// - /// Gets or sets the identifier. - /// - /// The identifier. - public string Id { get; set; } - - /// - /// Gets or sets the URL. - /// - /// The URL. - public string Url { get; set; } - - /// - /// Gets or sets the status. - /// - /// The status. - public LiveTvTunerStatus Status { get; set; } - - /// - /// Gets or sets the channel identifier. - /// - /// The channel identifier. - public string ChannelId { get; set; } - - /// - /// Gets or sets the recording identifier. - /// - /// The recording identifier. - public string RecordingId { get; set; } - - /// - /// Gets or sets the name of the program. - /// - /// The name of the program. - public string ProgramName { get; set; } - - /// - /// Gets or sets the clients. - /// - /// The clients. - public List Clients { get; set; } - - /// - /// Gets or sets a value indicating whether this instance can reset. - /// - /// true if this instance can reset; otherwise, false. - public bool CanReset { get; set; } - } -} diff --git a/MediaBrowser.Controller/LiveTv/RecordingInfo.cs b/MediaBrowser.Controller/LiveTv/RecordingInfo.cs deleted file mode 100644 index 1dcf7a58fe..0000000000 --- a/MediaBrowser.Controller/LiveTv/RecordingInfo.cs +++ /dev/null @@ -1,210 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using MediaBrowser.Model.LiveTv; - -namespace MediaBrowser.Controller.LiveTv -{ - public class RecordingInfo - { - public RecordingInfo() - { - Genres = new List(); - } - - /// - /// Gets or sets the id of the recording. - /// - public string Id { get; set; } - - /// - /// Gets or sets the series timer identifier. - /// - /// The series timer identifier. - public string SeriesTimerId { get; set; } - - /// - /// Gets or sets the timer identifier. - /// - /// The timer identifier. - public string TimerId { get; set; } - - /// - /// Gets or sets the channelId of the recording. - /// - public string ChannelId { get; set; } - - /// - /// Gets or sets the type of the channel. - /// - /// The type of the channel. - public ChannelType ChannelType { get; set; } - - /// - /// Gets or sets the name of the recording. - /// - public string Name { get; set; } - - /// - /// Gets or sets the path. - /// - /// The path. - public string Path { get; set; } - - /// - /// Gets or sets the URL. - /// - /// The URL. - public string Url { get; set; } - - /// - /// Gets or sets the overview. - /// - /// The overview. - public string Overview { get; set; } - - /// - /// Gets or sets the start date of the recording, in UTC. - /// - public DateTime StartDate { get; set; } - - /// - /// Gets or sets the end date of the recording, in UTC. - /// - public DateTime EndDate { get; set; } - - /// - /// Gets or sets the program identifier. - /// - /// The program identifier. - public string ProgramId { get; set; } - - /// - /// Gets or sets the status. - /// - /// The status. - public RecordingStatus Status { get; set; } - - /// - /// Gets or sets the genre of the program. - /// - public List Genres { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is repeat. - /// - /// true if this instance is repeat; otherwise, false. - public bool IsRepeat { get; set; } - - /// - /// Gets or sets the episode title. - /// - /// The episode title. - public string EpisodeTitle { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is hd. - /// - /// true if this instance is hd; otherwise, false. - public bool? IsHD { get; set; } - - /// - /// Gets or sets the audio. - /// - /// The audio. - public ProgramAudio? Audio { get; set; } - - /// - /// Gets or sets the original air date. - /// - /// The original air date. - public DateTime? OriginalAirDate { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is movie. - /// - /// true if this instance is movie; otherwise, false. - public bool IsMovie { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is sports. - /// - /// true if this instance is sports; otherwise, false. - public bool IsSports { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is series. - /// - /// true if this instance is series; otherwise, false. - public bool IsSeries { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is live. - /// - /// true if this instance is live; otherwise, false. - public bool IsLive { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is news. - /// - /// true if this instance is news; otherwise, false. - public bool IsNews { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is kids. - /// - /// true if this instance is kids; otherwise, false. - public bool IsKids { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is premiere. - /// - /// true if this instance is premiere; otherwise, false. - public bool IsPremiere { get; set; } - - /// - /// Gets or sets the official rating. - /// - /// The official rating. - public string OfficialRating { get; set; } - - /// - /// Gets or sets the community rating. - /// - /// The community rating. - public float? CommunityRating { get; set; } - - /// - /// Gets or sets the image path if it can be accessed directly from the file system. - /// - /// The image path. - public string ImagePath { get; set; } - - /// - /// Gets or sets the image url if it can be downloaded. - /// - /// The image URL. - public string ImageUrl { get; set; } - - /// - /// Gets or sets a value indicating whether this instance has image. - /// - /// null if [has image] contains no value, true if [has image]; otherwise, false. - public bool? HasImage { get; set; } - - /// - /// Gets or sets the show identifier. - /// - /// The show identifier. - public string ShowId { get; set; } - - /// - /// Gets or sets the date last updated. - /// - /// The date last updated. - public DateTime DateLastUpdated { get; set; } - } -} diff --git a/MediaBrowser.Controller/LiveTv/RecordingStatusChangedEventArgs.cs b/MediaBrowser.Controller/LiveTv/RecordingStatusChangedEventArgs.cs deleted file mode 100644 index 0b943c9396..0000000000 --- a/MediaBrowser.Controller/LiveTv/RecordingStatusChangedEventArgs.cs +++ /dev/null @@ -1,16 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using MediaBrowser.Model.LiveTv; - -namespace MediaBrowser.Controller.LiveTv -{ - public class RecordingStatusChangedEventArgs : EventArgs - { - public string RecordingId { get; set; } - - public RecordingStatus NewStatus { get; set; } - } -} diff --git a/MediaBrowser.Model/IO/IStreamHelper.cs b/MediaBrowser.Model/IO/IStreamHelper.cs index f900da5567..034a6bf8bf 100644 --- a/MediaBrowser.Model/IO/IStreamHelper.cs +++ b/MediaBrowser.Model/IO/IStreamHelper.cs @@ -13,8 +13,6 @@ namespace MediaBrowser.Model.IO Task CopyToAsync(Stream source, Stream destination, int bufferSize, int emptyReadLimit, CancellationToken cancellationToken); - Task CopyToAsync(Stream source, Stream destination, long copyLength, CancellationToken cancellationToken); - Task CopyUntilCancelled(Stream source, Stream target, int bufferSize, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Model/LiveTv/LiveTvTunerStatus.cs b/MediaBrowser.Model/LiveTv/LiveTvTunerStatus.cs deleted file mode 100644 index 80a6461957..0000000000 --- a/MediaBrowser.Model/LiveTv/LiveTvTunerStatus.cs +++ /dev/null @@ -1,12 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.LiveTv -{ - public enum LiveTvTunerStatus - { - Available = 0, - Disabled = 1, - RecordingTv = 2, - LiveTv = 3 - } -} diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index f5ce75ff4d..51abb503eb 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -113,15 +113,6 @@ namespace Jellyfin.LiveTv.Channels return channel is ISupportsDelete supportsDelete && supportsDelete.CanDelete(item); } - /// - public bool EnableMediaProbe(BaseItem item) - { - var internalChannel = _libraryManager.GetItemById(item.ChannelId); - var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); - - return channel is ISupportsMediaProbe; - } - /// public Task DeleteItem(BaseItem item) { @@ -562,18 +553,6 @@ namespace Jellyfin.LiveTv.Channels return GetChannelFeaturesDto(channel, channelProvider, channelProvider.GetChannelFeatures()); } - /// - /// Checks whether the provided Guid supports external transfer. - /// - /// The Guid. - /// Whether or not the provided Guid supports external transfer. - public bool SupportsExternalTransfer(Guid channelId) - { - var channelProvider = GetChannelProvider(channelId); - - return channelProvider.GetChannelFeatures().SupportsContentDownloading; - } - /// /// Gets the provided channel's supported features. /// @@ -1215,19 +1194,6 @@ namespace Jellyfin.LiveTv.Channels return result; } - internal IChannel GetChannelProvider(Guid internalChannelId) - { - var result = GetAllChannels() - .FirstOrDefault(i => internalChannelId.Equals(GetInternalChannelId(i.Name))); - - if (result is null) - { - throw new ResourceNotFoundException("No channel provider found for channel id " + internalChannelId); - } - - return result; - } - /// public void Dispose() { diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index 625451fa37..9eb3aa2fd6 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -1023,11 +1023,6 @@ namespace Jellyfin.LiveTv.EmbyTV return Task.CompletedTask; } - public Task RecordLiveStream(string id, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - public Task ResetTuner(string id, CancellationToken cancellationToken) { return Task.CompletedTask; diff --git a/src/Jellyfin.LiveTv/StreamHelper.cs b/src/Jellyfin.LiveTv/StreamHelper.cs index ab4b6e9b15..e9644e95e7 100644 --- a/src/Jellyfin.LiveTv/StreamHelper.cs +++ b/src/Jellyfin.LiveTv/StreamHelper.cs @@ -81,36 +81,6 @@ namespace Jellyfin.LiveTv } } - public async Task CopyToAsync(Stream source, Stream destination, long copyLength, CancellationToken cancellationToken) - { - byte[] buffer = ArrayPool.Shared.Rent(IODefaults.CopyToBufferSize); - try - { - int bytesRead; - - while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) - { - var bytesToWrite = Math.Min(bytesRead, copyLength); - - if (bytesToWrite > 0) - { - await destination.WriteAsync(buffer.AsMemory(0, Convert.ToInt32(bytesToWrite)), cancellationToken).ConfigureAwait(false); - } - - copyLength -= bytesToWrite; - - if (copyLength <= 0) - { - break; - } - } - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - public async Task CopyUntilCancelled(Stream source, Stream target, int bufferSize, CancellationToken cancellationToken) { byte[] buffer = ArrayPool.Shared.Rent(bufferSize); diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index a56af65a6f..fef84dd000 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; using System.Net; using System.Net.Http; @@ -16,7 +15,6 @@ using System.Threading.Tasks; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; -using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller; @@ -164,152 +162,6 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun } } - private async Task> GetTunerInfosHttp(TunerHostInfo info, CancellationToken cancellationToken) - { - var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); - - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/tuners.html", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken) - .ConfigureAwait(false); - var tuners = new List(); - var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) - { - using var sr = new StreamReader(stream, System.Text.Encoding.UTF8); - await foreach (var line in sr.ReadAllLinesAsync().ConfigureAwait(false)) - { - string stripedLine = StripXML(line); - if (stripedLine.Contains("Channel", StringComparison.Ordinal)) - { - LiveTvTunerStatus status; - var index = stripedLine.IndexOf("Channel", StringComparison.OrdinalIgnoreCase); - var name = stripedLine.Substring(0, index - 1); - var currentChannel = stripedLine.Substring(index + 7); - if (string.Equals(currentChannel, "none", StringComparison.Ordinal)) - { - status = LiveTvTunerStatus.LiveTv; - } - else - { - status = LiveTvTunerStatus.Available; - } - - tuners.Add(new LiveTvTunerInfo - { - Name = name, - SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber, - ProgramName = currentChannel, - Status = status - }); - } - } - } - - return tuners; - } - - private static string StripXML(string source) - { - if (string.IsNullOrEmpty(source)) - { - return string.Empty; - } - - char[] buffer = new char[source.Length]; - int bufferIndex = 0; - bool inside = false; - - for (int i = 0; i < source.Length; i++) - { - char let = source[i]; - if (let == '<') - { - inside = true; - continue; - } - - if (let == '>') - { - inside = false; - continue; - } - - if (!inside) - { - buffer[bufferIndex++] = let; - } - } - - return new string(buffer, 0, bufferIndex); - } - - private async Task> GetTunerInfosUdp(TunerHostInfo info, CancellationToken cancellationToken) - { - var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); - - var tuners = new List(model.TunerCount); - - var uri = new Uri(GetApiUrl(info)); - - using (var manager = new HdHomerunManager()) - { - // Legacy HdHomeruns are IPv4 only - var ipInfo = IPAddress.Parse(uri.Host); - - for (int i = 0; i < model.TunerCount; i++) - { - var name = string.Format(CultureInfo.InvariantCulture, "Tuner {0}", i + 1); - var currentChannel = "none"; // TODO: Get current channel and map back to Station Id - var isAvailable = await manager.CheckTunerAvailability(ipInfo, i, cancellationToken).ConfigureAwait(false); - var status = isAvailable ? LiveTvTunerStatus.Available : LiveTvTunerStatus.LiveTv; - tuners.Add(new LiveTvTunerInfo - { - Name = name, - SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber, - ProgramName = currentChannel, - Status = status - }); - } - } - - return tuners; - } - - public async Task> GetTunerInfos(CancellationToken cancellationToken) - { - var list = new List(); - - foreach (var host in Config.GetLiveTvConfiguration().TunerHosts - .Where(i => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase))) - { - try - { - list.AddRange(await GetTunerInfos(host, cancellationToken).ConfigureAwait(false)); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error getting tuner info"); - } - } - - return list; - } - - public async Task> GetTunerInfos(TunerHostInfo info, CancellationToken cancellationToken) - { - // TODO Need faster way to determine UDP vs HTTP - var channels = await GetChannels(info, true, cancellationToken).ConfigureAwait(false); - - var hdHomerunChannelInfo = channels.FirstOrDefault() as HdHomerunChannelInfo; - - if (hdHomerunChannelInfo is null || hdHomerunChannelInfo.IsLegacyTuner) - { - return await GetTunerInfosUdp(info, cancellationToken).ConfigureAwait(false); - } - - return await GetTunerInfosHttp(info, cancellationToken).ConfigureAwait(false); - } - private static string GetApiUrl(TunerHostInfo info) { var url = info.Url; @@ -575,40 +427,24 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun _streamHelper); } - var enableHttpStream = true; - if (enableHttpStream) - { - mediaSource.Protocol = MediaProtocol.Http; - - var httpUrl = channel.Path; - - // If raw was used, the tuner doesn't support params - if (!string.IsNullOrWhiteSpace(profile) && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreCase)) - { - httpUrl += "?transcode=" + profile; - } + mediaSource.Protocol = MediaProtocol.Http; - mediaSource.Path = httpUrl; + var httpUrl = channel.Path; - return new SharedHttpStream( - mediaSource, - tunerHost, - streamId, - FileSystem, - _httpClientFactory, - Logger, - Config, - _appHost, - _streamHelper); + // If raw was used, the tuner doesn't support params + if (!string.IsNullOrWhiteSpace(profile) && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreCase)) + { + httpUrl += "?transcode=" + profile; } - return new HdHomerunUdpStream( + mediaSource.Path = httpUrl; + + return new SharedHttpStream( mediaSource, tunerHost, streamId, - new HdHomerunChannelCommands(hdhomerunChannel.Number, profile), - modelInfo.TunerCount, FileSystem, + _httpClientFactory, Logger, Config, _appHost, diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs index 7235e65b64..3666d342ed 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs @@ -80,22 +80,6 @@ namespace Jellyfin.LiveTv.TunerHosts .ConfigureAwait(false); } - public Task> GetTunerInfos(CancellationToken cancellationToken) - { - var list = GetTunerHosts() - .Select(i => new LiveTvTunerInfo() - { - Name = Name, - SourceType = Type, - Status = LiveTvTunerStatus.Available, - Id = i.Url.GetMD5().ToString("N", CultureInfo.InvariantCulture), - Url = i.Url - }) - .ToList(); - - return Task.FromResult(list); - } - protected override async Task GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, IList currentLiveStreams, CancellationToken cancellationToken) { var tunerCount = tunerHost.TunerCount; -- cgit v1.2.3 From 70feba6540c1c4f6d9fbfacc974fc18d1da050b0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 13:33:08 -0700 Subject: chore(deps): update dependency xunit to v2.6.6 (#10867) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 294414ee12..3343971fe4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -85,6 +85,6 @@ - + -- cgit v1.2.3 From fa9b1d5f65d135ff3021cce60ce3ac2867837268 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 13:33:28 -0700 Subject: chore(deps): update dependency diacritics to v3.3.27 (#10862) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3343971fe4..b88e43f9db 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,7 @@ - + -- cgit v1.2.3 From 021bfd1ecd3b72013926fe7602c03ac9b6b89692 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 13:34:18 -0700 Subject: chore(deps): update dependency svg.skia to v1.0.0.10 (#10480) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b88e43f9db..bc87f9fc96 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -71,7 +71,7 @@ - + -- cgit v1.2.3 From d4ca845a260b0975b6c47287bec8ccfa1e545ed5 Mon Sep 17 00:00:00 2001 From: SuperDumbTM Date: Tue, 16 Jan 2024 09:00:34 +0000 Subject: Translated using Weblate (Chinese (Traditional, Hong Kong)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/zh_Hant_HK/ --- Emby.Server.Implementations/Localization/Core/zh-HK.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 66cbee3948..3ab9774c27 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -83,13 +83,13 @@ "UserDeletedWithName": "用戶 {0} 已被移除", "UserDownloadingItemWithValues": "{0} 正在下載 {1}", "UserLockedOutWithName": "用戶 {0} 已被封鎖", - "UserOfflineFromDevice": "{0} 從 {1} 斷開連接", + "UserOfflineFromDevice": "{0} 終止了 {1} 的連接", "UserOnlineFromDevice": "{0} 從 {1} 連線", - "UserPasswordChangedWithName": "{0} 的密碼已被變改", + "UserPasswordChangedWithName": "{0} 的密碼已被更改", "UserPolicyUpdatedWithName": "使用條款已更新為 {0}", "UserStartedPlayingItemWithValues": "{0} 在 {2} 上播放 {1}", - "UserStoppedPlayingItemWithValues": "{0} 已停止在 {2} 上播放 {1}", - "ValueHasBeenAddedToLibrary": "已添加 {0} 到你的媒體庫", + "UserStoppedPlayingItemWithValues": "{0} 停止在 {2} 上播放 {1}", + "ValueHasBeenAddedToLibrary": "{0} 已被加入至你的媒體庫", "ValueSpecialEpisodeName": "特典 - {0}", "VersionNumber": "版本 {0}", "TaskDownloadMissingSubtitles": "下載欠缺字幕", -- cgit v1.2.3 From bf57faff6506df82ba2302ae08e5f1da5698798e Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 16 Jan 2024 16:20:37 +0000 Subject: Translated using Weblate (Georgian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ka/ --- Emby.Server.Implementations/Localization/Core/ka.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/ka.json b/Emby.Server.Implementations/Localization/Core/ka.json index 5368b8eb6e..2d02522fea 100644 --- a/Emby.Server.Implementations/Localization/Core/ka.json +++ b/Emby.Server.Implementations/Localization/Core/ka.json @@ -4,9 +4,9 @@ "HeaderFavoriteAlbums": "რჩეული ალბომები", "TasksApplicationCategory": "აპლიკაცია", "Albums": "ალბომები", - "AppDeviceValues": "აპი: {0}, მოწყობილობა: {1}", + "AppDeviceValues": "აპლიკაცია: {0}, მოწყობილობა: {1}", "Application": "აპლიკაცია", - "Artists": "შემსრულებლები", + "Artists": "არტისტი", "AuthenticationSucceededWithUserName": "{0} -ის ავთენტიკაცია წარმატებულია", "Books": "წიგნები", "Forced": "ძალით", -- cgit v1.2.3 From 244a739675a140be4c6ad88ee8028cdadec4ac58 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Tue, 16 Jan 2024 20:24:28 +0100 Subject: Fix incorrect path check in CleanupCollectionAndPlaylistPathsTask --- .../ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs index acd4bf9056..812df81922 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs @@ -115,9 +115,10 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask List? itemsToRemove = null; foreach (var linkedChild in folder.LinkedChildren) { - if (!File.Exists(folder.Path)) + var path = linkedChild.Path; + if (!File.Exists(path)) { - _logger.LogInformation("Item in {FolderName} cannot be found at {ItemPath}", folder.Name, linkedChild.Path); + _logger.LogInformation("Item in {FolderName} cannot be found at {ItemPath}", folder.Name, path); (itemsToRemove ??= new List()).Add(linkedChild); } } -- cgit v1.2.3 From e7b8d45bbb0f2b832245dae7ac0d401c56cb10a4 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Wed, 17 Jan 2024 08:51:39 -0700 Subject: Use helper function to compare guid (#10825) --- .../Data/SqliteItemRepository.cs | 32 +++++++++++----------- .../EntryPoints/LibraryChangedNotifier.cs | 3 +- .../Library/LibraryManager.cs | 30 ++++++++++---------- .../Library/MediaSourceManager.cs | 5 ++-- .../Library/MusicManager.cs | 3 +- .../Library/SearchEngine.cs | 4 +-- .../Library/UserViewManager.cs | 5 ++-- .../Playlists/PlaylistManager.cs | 3 +- .../Session/SessionManager.cs | 22 +++++++-------- Emby.Server.Implementations/SyncPlay/Group.cs | 3 +- Emby.Server.Implementations/TV/TVSeriesManager.cs | 7 +++-- .../Updates/InstallationManager.cs | 3 +- .../DefaultAuthorizationHandler.cs | 3 +- .../FirstTimeSetupPolicy/FirstTimeSetupHandler.cs | 3 +- Jellyfin.Api/Controllers/ArtistsController.cs | 7 +++-- Jellyfin.Api/Controllers/ChannelsController.cs | 5 ++-- Jellyfin.Api/Controllers/FilterController.cs | 5 ++-- Jellyfin.Api/Controllers/GenresController.cs | 5 ++-- Jellyfin.Api/Controllers/InstantMixController.cs | 15 +++++----- Jellyfin.Api/Controllers/ItemsController.cs | 5 ++-- Jellyfin.Api/Controllers/LibraryController.cs | 26 +++++++++--------- Jellyfin.Api/Controllers/LiveTvController.cs | 25 +++++++++-------- Jellyfin.Api/Controllers/MoviesController.cs | 3 +- Jellyfin.Api/Controllers/MusicGenresController.cs | 5 ++-- Jellyfin.Api/Controllers/PersonsController.cs | 5 ++-- Jellyfin.Api/Controllers/PlaylistsController.cs | 3 +- Jellyfin.Api/Controllers/SearchController.cs | 2 +- Jellyfin.Api/Controllers/SessionController.cs | 7 +++-- Jellyfin.Api/Controllers/StudiosController.cs | 5 ++-- Jellyfin.Api/Controllers/SuggestionsController.cs | 3 +- Jellyfin.Api/Controllers/TvShowsController.cs | 10 +++---- Jellyfin.Api/Controllers/UserController.cs | 3 +- Jellyfin.Api/Controllers/UserLibraryController.cs | 19 +++++++------ Jellyfin.Api/Controllers/VideosController.cs | 7 +++-- Jellyfin.Api/Controllers/YearsController.cs | 6 ++-- Jellyfin.Api/Helpers/MediaInfoHelper.cs | 3 +- Jellyfin.Api/Helpers/RequestHelpers.cs | 3 +- Jellyfin.Api/Helpers/StreamingHelpers.cs | 2 +- .../Users/UserManager.cs | 3 +- .../Entities/AggregateFolder.cs | 3 +- .../Entities/Audio/MusicArtist.cs | 2 +- MediaBrowser.Controller/Entities/BaseItem.cs | 16 +++++------ MediaBrowser.Controller/Entities/Folder.cs | 9 +++--- MediaBrowser.Controller/Entities/TV/Episode.cs | 11 ++++---- MediaBrowser.Controller/Entities/TV/Season.cs | 5 ++-- MediaBrowser.Controller/Entities/UserView.cs | 8 +++--- .../Entities/UserViewBuilder.cs | 2 +- MediaBrowser.Controller/Entities/Video.cs | 2 +- .../Transcoding/TranscodeManager.cs | 3 +- MediaBrowser.Model/Dlna/StreamBuilder.cs | 3 +- MediaBrowser.Providers/Manager/ProviderManager.cs | 4 +-- src/Jellyfin.Extensions/GuidExtensions.cs | 26 ++++++++++++++++++ .../Json/Converters/JsonNullableGuidConverter.cs | 2 +- src/Jellyfin.LiveTv/Channels/ChannelManager.cs | 8 +++--- src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 6 ++-- src/Jellyfin.LiveTv/LiveTvDtoService.cs | 5 ++-- src/Jellyfin.LiveTv/LiveTvManager.cs | 7 +++-- .../AuthHelper.cs | 3 +- 58 files changed, 249 insertions(+), 184 deletions(-) create mode 100644 src/Jellyfin.Extensions/GuidExtensions.cs diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index d0772654ce..a6336f1451 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -699,7 +699,7 @@ namespace Emby.Server.Implementations.Data saveItemStatement.TryBindNull("@EndDate"); } - saveItemStatement.TryBind("@ChannelId", item.ChannelId.Equals(default) ? null : item.ChannelId.ToString("N", CultureInfo.InvariantCulture)); + saveItemStatement.TryBind("@ChannelId", item.ChannelId.IsEmpty() ? null : item.ChannelId.ToString("N", CultureInfo.InvariantCulture)); if (item is IHasProgramAttributes hasProgramAttributes) { @@ -729,7 +729,7 @@ namespace Emby.Server.Implementations.Data saveItemStatement.TryBind("@ProductionYear", item.ProductionYear); var parentId = item.ParentId; - if (parentId.Equals(default)) + if (parentId.IsEmpty()) { saveItemStatement.TryBindNull("@ParentId"); } @@ -925,7 +925,7 @@ namespace Emby.Server.Implementations.Data { saveItemStatement.TryBind("@SeasonName", episode.SeasonName); - var nullableSeasonId = episode.SeasonId.Equals(default) ? (Guid?)null : episode.SeasonId; + var nullableSeasonId = episode.SeasonId.IsEmpty() ? (Guid?)null : episode.SeasonId; saveItemStatement.TryBind("@SeasonId", nullableSeasonId); } @@ -937,7 +937,7 @@ namespace Emby.Server.Implementations.Data if (item is IHasSeries hasSeries) { - var nullableSeriesId = hasSeries.SeriesId.Equals(default) ? (Guid?)null : hasSeries.SeriesId; + var nullableSeriesId = hasSeries.SeriesId.IsEmpty() ? (Guid?)null : hasSeries.SeriesId; saveItemStatement.TryBind("@SeriesId", nullableSeriesId); saveItemStatement.TryBind("@SeriesPresentationUniqueKey", hasSeries.SeriesPresentationUniqueKey); @@ -1010,7 +1010,7 @@ namespace Emby.Server.Implementations.Data } Guid ownerId = item.OwnerId; - if (ownerId.Equals(default)) + if (ownerId.IsEmpty()) { saveItemStatement.TryBindNull("@OwnerId"); } @@ -1266,7 +1266,7 @@ namespace Emby.Server.Implementations.Data /// is . public BaseItem RetrieveItem(Guid id) { - if (id.Equals(default)) + if (id.IsEmpty()) { throw new ArgumentException("Guid can't be empty", nameof(id)); } @@ -1970,7 +1970,7 @@ namespace Emby.Server.Implementations.Data { CheckDisposed(); - if (id.Equals(default)) + if (id.IsEmpty()) { throw new ArgumentNullException(nameof(id)); } @@ -3230,7 +3230,7 @@ namespace Emby.Server.Implementations.Data whereClauses.Add($"ChannelId in ({inClause})"); } - if (!query.ParentId.Equals(default)) + if (!query.ParentId.IsEmpty()) { whereClauses.Add("ParentId=@ParentId"); statement?.TryBind("@ParentId", query.ParentId); @@ -4452,7 +4452,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type public void DeleteItem(Guid id) { - if (id.Equals(default)) + if (id.IsEmpty()) { throw new ArgumentNullException(nameof(id)); } @@ -4583,13 +4583,13 @@ AND Type = @InternalPersonType)"); statement?.TryBind("@UserId", query.User.InternalId); } - if (!query.ItemId.Equals(default)) + if (!query.ItemId.IsEmpty()) { whereClauses.Add("ItemId=@ItemId"); statement?.TryBind("@ItemId", query.ItemId); } - if (!query.AppearsInItemId.Equals(default)) + if (!query.AppearsInItemId.IsEmpty()) { whereClauses.Add("p.Name in (Select Name from People where ItemId=@AppearsInItemId)"); statement?.TryBind("@AppearsInItemId", query.AppearsInItemId); @@ -4640,7 +4640,7 @@ AND Type = @InternalPersonType)"); private void UpdateAncestors(Guid itemId, List ancestorIds, SqliteConnection db, SqliteCommand deleteAncestorsStatement) { - if (itemId.Equals(default)) + if (itemId.IsEmpty()) { throw new ArgumentNullException(nameof(itemId)); } @@ -5156,7 +5156,7 @@ AND Type = @InternalPersonType)"); private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, SqliteConnection db) { - if (itemId.Equals(default)) + if (itemId.IsEmpty()) { throw new ArgumentNullException(nameof(itemId)); } @@ -5228,7 +5228,7 @@ AND Type = @InternalPersonType)"); public void UpdatePeople(Guid itemId, List people) { - if (itemId.Equals(default)) + if (itemId.IsEmpty()) { throw new ArgumentNullException(nameof(itemId)); } @@ -5378,7 +5378,7 @@ AND Type = @InternalPersonType)"); { CheckDisposed(); - if (id.Equals(default)) + if (id.IsEmpty()) { throw new ArgumentNullException(nameof(id)); } @@ -5758,7 +5758,7 @@ AND Type = @InternalPersonType)"); CancellationToken cancellationToken) { CheckDisposed(); - if (id.Equals(default)) + if (id.IsEmpty()) { throw new ArgumentException("Guid can't be empty.", nameof(id)); } diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index a83d7a4105..83e7b230df 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Events; +using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -241,7 +242,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint { var userIds = _sessionManager.Sessions .Select(i => i.UserId) - .Where(i => !i.Equals(default)) + .Where(i => !i.IsEmpty()) .Distinct() .ToArray(); diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index a79ffd9cb1..8ae913dad8 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -732,7 +732,7 @@ namespace Emby.Server.Implementations.Library Path = path }; - if (folder.Id.Equals(default)) + if (folder.Id.IsEmpty()) { if (string.IsNullOrEmpty(folder.Path)) { @@ -1219,7 +1219,7 @@ namespace Emby.Server.Implementations.Library /// is null. public BaseItem GetItemById(Guid id) { - if (id.Equals(default)) + if (id.IsEmpty()) { throw new ArgumentException("Guid can't be empty", nameof(id)); } @@ -1241,7 +1241,7 @@ namespace Emby.Server.Implementations.Library public List GetItemList(InternalItemsQuery query, bool allowExternalContent) { - if (query.Recursive && !query.ParentId.Equals(default)) + if (query.Recursive && !query.ParentId.IsEmpty()) { var parent = GetItemById(query.ParentId); if (parent is not null) @@ -1272,7 +1272,7 @@ namespace Emby.Server.Implementations.Library public int GetCount(InternalItemsQuery query) { - if (query.Recursive && !query.ParentId.Equals(default)) + if (query.Recursive && !query.ParentId.IsEmpty()) { var parent = GetItemById(query.ParentId); if (parent is not null) @@ -1430,7 +1430,7 @@ namespace Emby.Server.Implementations.Library public QueryResult GetItemsResult(InternalItemsQuery query) { - if (query.Recursive && !query.ParentId.Equals(default)) + if (query.Recursive && !query.ParentId.IsEmpty()) { var parent = GetItemById(query.ParentId); if (parent is not null) @@ -1486,7 +1486,7 @@ namespace Emby.Server.Implementations.Library private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true) { if (query.AncestorIds.Length == 0 && - query.ParentId.Equals(default) && + query.ParentId.IsEmpty() && query.ChannelIds.Count == 0 && query.TopParentIds.Length == 0 && string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) && @@ -1520,7 +1520,7 @@ namespace Emby.Server.Implementations.Library } // Translate view into folders - if (!view.DisplayParentId.Equals(default)) + if (!view.DisplayParentId.IsEmpty()) { var displayParent = GetItemById(view.DisplayParentId); if (displayParent is not null) @@ -1531,7 +1531,7 @@ namespace Emby.Server.Implementations.Library return Array.Empty(); } - if (!view.ParentId.Equals(default)) + if (!view.ParentId.IsEmpty()) { var displayParent = GetItemById(view.ParentId); if (displayParent is not null) @@ -2137,7 +2137,7 @@ namespace Emby.Server.Implementations.Library return null; } - while (!item.ParentId.Equals(default)) + while (!item.ParentId.IsEmpty()) { var parent = item.GetParent(); if (parent is null || parent is AggregateFolder) @@ -2215,7 +2215,7 @@ namespace Emby.Server.Implementations.Library CollectionType? viewType, string sortName) { - var parentIdString = parentId.Equals(default) + var parentIdString = parentId.IsEmpty() ? null : parentId.ToString("N", CultureInfo.InvariantCulture); var idValues = "38_namedview_" + name + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); @@ -2251,7 +2251,7 @@ namespace Emby.Server.Implementations.Library var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval; - if (!refresh && !item.DisplayParentId.Equals(default)) + if (!refresh && !item.DisplayParentId.IsEmpty()) { var displayParent = GetItemById(item.DisplayParentId); refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed; @@ -2315,7 +2315,7 @@ namespace Emby.Server.Implementations.Library var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval; - if (!refresh && !item.DisplayParentId.Equals(default)) + if (!refresh && !item.DisplayParentId.IsEmpty()) { var displayParent = GetItemById(item.DisplayParentId); refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed; @@ -2345,7 +2345,7 @@ namespace Emby.Server.Implementations.Library { ArgumentException.ThrowIfNullOrEmpty(name); - var parentIdString = parentId.Equals(default) + var parentIdString = parentId.IsEmpty() ? null : parentId.ToString("N", CultureInfo.InvariantCulture); var idValues = "37_namedview_" + name + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); @@ -2391,7 +2391,7 @@ namespace Emby.Server.Implementations.Library var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval; - if (!refresh && !item.DisplayParentId.Equals(default)) + if (!refresh && !item.DisplayParentId.IsEmpty()) { var displayParent = GetItemById(item.DisplayParentId); refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed; @@ -2419,7 +2419,7 @@ namespace Emby.Server.Implementations.Library return GetItemById(parentId.Value); } - if (userId.HasValue && !userId.Equals(default)) + if (!userId.IsNullOrEmpty()) { return GetUserRootFolder(); } diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 68eccf311d..c38f1af912 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -13,6 +13,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -524,10 +525,10 @@ namespace Emby.Server.Implementations.Library _logger.LogInformation("Live stream opened: {@MediaSource}", mediaSource); var clone = JsonSerializer.Deserialize(json, _jsonOptions); - if (!request.UserId.Equals(default)) + if (!request.UserId.IsEmpty()) { var user = _userManager.GetUserById(request.UserId); - var item = request.ItemId.Equals(default) + var item = request.ItemId.IsEmpty() ? null : _libraryManager.GetItemById(request.ItemId); SetDefaultAudioAndSubtitleStreamIndexes(item, clone, user); diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index b2439a87e1..078f4ad219 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -80,7 +81,7 @@ namespace Emby.Server.Implementations.Library { return Guid.Empty; } - }).Where(i => !i.Equals(default)).ToArray(); + }).Where(i => !i.IsEmpty()).ToArray(); return GetInstantMixFromGenreIds(genreIds, user, dtoOptions); } diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs index b916b91708..020cb517d1 100644 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ b/Emby.Server.Implementations/Library/SearchEngine.cs @@ -30,7 +30,7 @@ namespace Emby.Server.Implementations.Library public QueryResult GetSearchHints(SearchQuery query) { User user = null; - if (!query.UserId.Equals(default)) + if (!query.UserId.IsEmpty()) { user = _userManager.GetUserById(query.UserId); } @@ -177,7 +177,7 @@ namespace Emby.Server.Implementations.Library if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) { - if (!searchQuery.ParentId.Equals(default)) + if (!searchQuery.ParentId.IsEmpty()) { searchQuery.AncestorIds = new[] { searchQuery.ParentId }; searchQuery.ParentId = Guid.Empty; diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 1d662ed8dd..83a66c8e47 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; @@ -151,7 +152,7 @@ namespace Emby.Server.Implementations.Library var index = Array.IndexOf(orders, i.Id); if (index == -1 && i is UserView view - && !view.DisplayParentId.Equals(default)) + && !view.DisplayParentId.IsEmpty()) { index = Array.IndexOf(orders, view.DisplayParentId); } @@ -253,7 +254,7 @@ namespace Emby.Server.Implementations.Library var parents = new List(); - if (!parentId.Equals(default)) + if (!parentId.IsEmpty()) { var parentItem = _libraryManager.GetItemById(parentId); if (parentItem is Channel) diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index d2e2fd7d56..aea8d65322 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -11,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -178,7 +179,7 @@ namespace Emby.Server.Implementations.Playlists public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection itemIds, Guid userId) { - var user = userId.Equals(default) ? null : _userManager.GetUserById(userId); + var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId); return AddToPlaylistInternal(playlistId, itemIds, user, new DtoOptions(false) { diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index f457a78b3b..bbb3938dcf 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -337,7 +337,7 @@ namespace Emby.Server.Implementations.Session info.MediaSourceId = info.ItemId.ToString("N", CultureInfo.InvariantCulture); } - if (!info.ItemId.Equals(default) && info.Item is null && libraryItem is not null) + if (!info.ItemId.IsEmpty() && info.Item is null && libraryItem is not null) { var current = session.NowPlayingItem; @@ -529,7 +529,7 @@ namespace Emby.Server.Implementations.Session { var users = new List(); - if (session.UserId.Equals(default)) + if (session.UserId.IsEmpty()) { return users; } @@ -690,7 +690,7 @@ namespace Emby.Server.Implementations.Session var session = GetSession(info.SessionId); - var libraryItem = info.ItemId.Equals(default) + var libraryItem = info.ItemId.IsEmpty() ? null : GetNowPlayingItem(session, info.ItemId); @@ -784,7 +784,7 @@ namespace Emby.Server.Implementations.Session var session = GetSession(info.SessionId); - var libraryItem = info.ItemId.Equals(default) + var libraryItem = info.ItemId.IsEmpty() ? null : GetNowPlayingItem(session, info.ItemId); @@ -923,7 +923,7 @@ namespace Emby.Server.Implementations.Session session.StopAutomaticProgress(); - var libraryItem = info.ItemId.Equals(default) + var libraryItem = info.ItemId.IsEmpty() ? null : GetNowPlayingItem(session, info.ItemId); @@ -933,7 +933,7 @@ namespace Emby.Server.Implementations.Session info.MediaSourceId = info.ItemId.ToString("N", CultureInfo.InvariantCulture); } - if (!info.ItemId.Equals(default) && info.Item is null && libraryItem is not null) + if (!info.ItemId.IsEmpty() && info.Item is null && libraryItem is not null) { var current = session.NowPlayingItem; @@ -1154,7 +1154,7 @@ namespace Emby.Server.Implementations.Session var session = GetSessionToRemoteControl(sessionId); - var user = session.UserId.Equals(default) ? null : _userManager.GetUserById(session.UserId); + var user = session.UserId.IsEmpty() ? null : _userManager.GetUserById(session.UserId); List items; @@ -1223,7 +1223,7 @@ namespace Emby.Server.Implementations.Session { var controllingSession = GetSession(controllingSessionId); AssertCanControl(session, controllingSession); - if (!controllingSession.UserId.Equals(default)) + if (!controllingSession.UserId.IsEmpty()) { command.ControllingUserId = controllingSession.UserId; } @@ -1342,7 +1342,7 @@ namespace Emby.Server.Implementations.Session { var controllingSession = GetSession(controllingSessionId); AssertCanControl(session, controllingSession); - if (!controllingSession.UserId.Equals(default)) + if (!controllingSession.UserId.IsEmpty()) { command.ControllingUserId = controllingSession.UserId.ToString("N", CultureInfo.InvariantCulture); } @@ -1463,7 +1463,7 @@ namespace Emby.Server.Implementations.Session ArgumentException.ThrowIfNullOrEmpty(request.AppVersion); User user = null; - if (!request.UserId.Equals(default)) + if (!request.UserId.IsEmpty()) { user = _userManager.GetUserById(request.UserId); } @@ -1766,7 +1766,7 @@ namespace Emby.Server.Implementations.Session { ArgumentNullException.ThrowIfNull(info); - var user = info.UserId.Equals(default) + var user = info.UserId.IsEmpty() ? null : _userManager.GetUserById(info.UserId); diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs index da8f949326..a7821c0e0e 100644 --- a/Emby.Server.Implementations/SyncPlay/Group.cs +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; @@ -553,7 +554,7 @@ namespace Emby.Server.Implementations.SyncPlay if (playingItemRemoved) { var itemId = PlayQueue.GetPlayingItemId(); - if (!itemId.Equals(default)) + if (!itemId.IsEmpty()) { var item = _libraryManager.GetItemById(itemId); RunTimeTicks = item.RunTimeTicks ?? 0; diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index ef890aeb4f..34c9e86f26 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -41,7 +42,7 @@ namespace Emby.Server.Implementations.TV } string? presentationUniqueKey = null; - if (query.SeriesId.HasValue && !query.SeriesId.Value.Equals(default)) + if (!query.SeriesId.IsNullOrEmpty()) { if (_libraryManager.GetItemById(query.SeriesId.Value) is Series series) { @@ -91,7 +92,7 @@ namespace Emby.Server.Implementations.TV string? presentationUniqueKey = null; int? limit = null; - if (request.SeriesId.HasValue && !request.SeriesId.Value.Equals(default)) + if (!request.SeriesId.IsNullOrEmpty()) { if (_libraryManager.GetItemById(request.SeriesId.Value) is Series series) { @@ -146,7 +147,7 @@ namespace Emby.Server.Implementations.TV // If viewing all next up for all series, remove first episodes // But if that returns empty, keep those first episodes (avoid completely empty view) - var alwaysEnableFirstEpisode = request.SeriesId.HasValue && !request.SeriesId.Value.Equals(default); + var alwaysEnableFirstEpisode = !request.SeriesId.IsNullOrEmpty(); var anyFound = false; return allNextUp diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 15c4cfdf02..ce3d6cab88 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; @@ -227,7 +228,7 @@ namespace Emby.Server.Implementations.Updates availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } - if (!id.Equals(default)) + if (!id.IsEmpty()) { availablePackages = availablePackages.Where(x => x.Id.Equals(id)); } diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs index cf3cb69052..7d0fe55898 100644 --- a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs @@ -2,6 +2,7 @@ using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Library; @@ -41,7 +42,7 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy var isApiKey = context.User.GetIsApiKey(); var userId = context.User.GetUserId(); // This likely only happens during the wizard, so skip the default checks and let any other handlers do it - if (!isApiKey && userId.Equals(default)) + if (!isApiKey && userId.IsEmpty()) { return Task.CompletedTask; } diff --git a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs index 688a13bc0b..965b7e7e60 100644 --- a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs +++ b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; @@ -46,7 +47,7 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy } var userId = contextUser.GetUserId(); - if (userId.Equals(default)) + if (userId.IsEmpty()) { context.Fail(); return Task.CompletedTask; diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index e7d3e694ab..8b931f1621 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -6,6 +6,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -126,7 +127,7 @@ public class ArtistsController : BaseJellyfinApiController User? user = null; BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); - if (!userId.Value.Equals(default)) + if (!userId.IsNullOrEmpty()) { user = _userManager.GetUserById(userId.Value); } @@ -330,7 +331,7 @@ public class ArtistsController : BaseJellyfinApiController User? user = null; BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); - if (!userId.Value.Equals(default)) + if (!userId.IsNullOrEmpty()) { user = _userManager.GetUserById(userId.Value); } @@ -469,7 +470,7 @@ public class ArtistsController : BaseJellyfinApiController var item = _libraryManager.GetArtist(name, dtoOptions); - if (!userId.Value.Equals(default)) + if (!userId.IsNullOrEmpty()) { var user = _userManager.GetUserById(userId.Value); diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs index fdc16ee23f..f83c71b578 100644 --- a/Jellyfin.Api/Controllers/ChannelsController.cs +++ b/Jellyfin.Api/Controllers/ChannelsController.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -126,7 +127,7 @@ public class ChannelsController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -201,7 +202,7 @@ public class ChannelsController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index baeb8b81a0..d6e043e6a1 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -3,6 +3,7 @@ using System.Linq; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -53,7 +54,7 @@ public class FilterController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -146,7 +147,7 @@ public class FilterController : BaseJellyfinApiController [FromQuery] bool? recursive) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index 6cb1993e46..54d48aec21 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -6,6 +6,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -95,7 +96,7 @@ public class GenresController : BaseJellyfinApiController .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = userId.Value.Equals(default) + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -172,7 +173,7 @@ public class GenresController : BaseJellyfinApiController item ??= new Genre(); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index 4dc2a4253d..e7ff1f9868 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -5,6 +5,7 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -76,7 +77,7 @@ public class InstantMixController : BaseJellyfinApiController { var item = _libraryManager.GetItemById(id); userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } @@ -113,7 +114,7 @@ public class InstantMixController : BaseJellyfinApiController { var album = _libraryManager.GetItemById(id); userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } @@ -150,7 +151,7 @@ public class InstantMixController : BaseJellyfinApiController { var playlist = (Playlist)_libraryManager.GetItemById(id); userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } @@ -186,7 +187,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } @@ -223,7 +224,7 @@ public class InstantMixController : BaseJellyfinApiController { var item = _libraryManager.GetItemById(id); userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } @@ -260,7 +261,7 @@ public class InstantMixController : BaseJellyfinApiController { var item = _libraryManager.GetItemById(id); userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } @@ -334,7 +335,7 @@ public class InstantMixController : BaseJellyfinApiController { var item = _libraryManager.GetItemById(id); userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index a1fc8e11b2..d10fba920c 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -5,6 +5,7 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -245,7 +246,7 @@ public class ItemsController : BaseJellyfinApiController var isApiKey = User.GetIsApiKey(); // if api key is used (auth.IsApiKey == true), then `user` will be null throughout this method userId = RequestHelpers.GetUserId(User, userId); - var user = !isApiKey && !userId.Value.Equals(default) + var user = !isApiKey && !userId.IsNullOrEmpty() ? _userManager.GetUserById(userId.Value) ?? throw new ResourceNotFoundException() : null; @@ -840,7 +841,7 @@ public class ItemsController : BaseJellyfinApiController var ancestorIds = Array.Empty(); var excludeFolderIds = user.GetPreferenceValues(PreferenceKind.LatestItemExcludes); - if (parentIdGuid.Equals(default) && excludeFolderIds.Length > 0) + if (parentIdGuid.IsEmpty() && excludeFolderIds.Length > 0) { ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true) .Where(i => i is Folder) diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index de057bbab9..a0bbc961f0 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -146,12 +146,12 @@ public class LibraryController : BaseJellyfinApiController [FromQuery] bool inheritFromParent = false) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - var item = itemId.Equals(default) - ? (userId.Value.Equals(default) + var item = itemId.IsEmpty() + ? (userId.IsNullOrEmpty() ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) : _libraryManager.GetItemById(itemId); @@ -213,12 +213,12 @@ public class LibraryController : BaseJellyfinApiController [FromQuery] bool inheritFromParent = false) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - var item = itemId.Equals(default) - ? (userId.Value.Equals(default) + var item = itemId.IsEmpty() + ? (userId.IsNullOrEmpty() ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) : _libraryManager.GetItemById(itemId); @@ -339,7 +339,7 @@ public class LibraryController : BaseJellyfinApiController { var isApiKey = User.GetIsApiKey(); var userId = User.GetUserId(); - var user = !isApiKey && !userId.Equals(default) + var user = !isApiKey && !userId.IsEmpty() ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException() : null; if (!isApiKey && user is null) @@ -382,7 +382,7 @@ public class LibraryController : BaseJellyfinApiController { var isApiKey = User.GetIsApiKey(); var userId = User.GetUserId(); - var user = !isApiKey && !userId.Equals(default) + var user = !isApiKey && !userId.IsEmpty() ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException() : null; @@ -428,7 +428,7 @@ public class LibraryController : BaseJellyfinApiController [FromQuery] bool? isFavorite) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -471,7 +471,7 @@ public class LibraryController : BaseJellyfinApiController var baseItemDtos = new List(); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -702,8 +702,8 @@ public class LibraryController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) { userId = RequestHelpers.GetUserId(User, userId); - var item = itemId.Equals(default) - ? (userId.Value.Equals(default) + var item = itemId.IsEmpty() + ? (userId.IsNullOrEmpty() ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) : _libraryManager.GetItemById(itemId); @@ -718,7 +718,7 @@ public class LibraryController : BaseJellyfinApiController return new QueryResult(); } - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 27eb88b60f..1b2f5750f6 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -15,6 +15,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LiveTvDtos; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; @@ -182,7 +183,7 @@ public class LiveTvController : BaseJellyfinApiController dtoOptions, CancellationToken.None); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -214,10 +215,10 @@ public class LiveTvController : BaseJellyfinApiController public ActionResult GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - var item = channelId.Equals(default) + var item = channelId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(channelId); @@ -387,7 +388,7 @@ public class LiveTvController : BaseJellyfinApiController public async Task>> GetRecordingFolders([FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var folders = await _liveTvManager.GetRecordingFoldersAsync(user).ConfigureAwait(false); @@ -410,10 +411,10 @@ public class LiveTvController : BaseJellyfinApiController public ActionResult GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - var item = recordingId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId); + var item = recordingId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId); var dtoOptions = new DtoOptions() .AddClientFields(User); @@ -567,7 +568,7 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] bool enableTotalRecordCount = true) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -594,7 +595,7 @@ public class LiveTvController : BaseJellyfinApiController GenreIds = genreIds }; - if (librarySeriesId.HasValue && !librarySeriesId.Equals(default)) + if (!librarySeriesId.IsNullOrEmpty()) { query.IsSeries = true; @@ -623,7 +624,7 @@ public class LiveTvController : BaseJellyfinApiController [Authorize(Policy = Policies.LiveTvAccess)] public async Task>> GetPrograms([FromBody] GetProgramsDto body) { - var user = body.UserId.Equals(default) ? null : _userManager.GetUserById(body.UserId); + var user = body.UserId.IsEmpty() ? null : _userManager.GetUserById(body.UserId); var query = new InternalItemsQuery(user) { @@ -648,7 +649,7 @@ public class LiveTvController : BaseJellyfinApiController GenreIds = body.GenreIds }; - if (!body.LibrarySeriesId.Equals(default)) + if (!body.LibrarySeriesId.IsEmpty()) { query.IsSeries = true; @@ -707,7 +708,7 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] bool enableTotalRecordCount = true) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -746,7 +747,7 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index e1145481fa..471bcd096e 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -7,6 +7,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; @@ -69,7 +70,7 @@ public class MoviesController : BaseJellyfinApiController [FromQuery] int itemLimit = 8) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index 69b9042646..5411baa3e7 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -6,6 +6,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -95,7 +96,7 @@ public class MusicGenresController : BaseJellyfinApiController .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = userId.Value.Equals(default) + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -164,7 +165,7 @@ public class MusicGenresController : BaseJellyfinApiController return NotFound(); } - if (!userId.Value.Equals(default)) + if (!userId.IsNullOrEmpty()) { var user = _userManager.GetUserById(userId.Value); diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index b4c6f490a0..6ca3086015 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -5,6 +5,7 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -83,7 +84,7 @@ public class PersonsController : BaseJellyfinApiController .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - User? user = userId.Value.Equals(default) + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -129,7 +130,7 @@ public class PersonsController : BaseJellyfinApiController return NotFound(); } - if (!userId.Value.Equals(default)) + if (!userId.IsNullOrEmpty()) { var user = _userManager.GetUserById(userId.Value); return _dtoService.GetBaseItemDto(item, dtoOptions, user); diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index c4c89ccde0..921cc6031f 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -9,6 +9,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.PlaylistDtos; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; @@ -188,7 +189,7 @@ public class PlaylistsController : BaseJellyfinApiController return NotFound(); } - var user = userId.Equals(default) + var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId); diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index 5b45941657..413b7b8340 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -209,7 +209,7 @@ public class SearchController : BaseJellyfinApiController break; } - if (!item.ChannelId.Equals(default)) + if (!item.ChannelId.IsEmpty()) { var channel = _libraryManager.GetItemById(item.ChannelId); result.ChannelName = channel?.Name; diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 083515a949..52b58b8f10 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -10,6 +10,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.SessionDtos; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; @@ -71,7 +72,7 @@ public class SessionController : BaseJellyfinApiController result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); } - if (controllableByUserId.HasValue && !controllableByUserId.Equals(default)) + if (!controllableByUserId.IsNullOrEmpty()) { result = result.Where(i => i.SupportsRemoteControl); @@ -83,12 +84,12 @@ public class SessionController : BaseJellyfinApiController if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) { - result = result.Where(i => i.UserId.Equals(default) || i.ContainsUser(controllableByUserId.Value)); + result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(controllableByUserId.Value)); } if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) { - result = result.Where(i => !i.UserId.Equals(default)); + result = result.Where(i => !i.UserId.IsEmpty()); } result = result.Where(i => diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index f434f60f51..708fc7436f 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -5,6 +5,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -91,7 +92,7 @@ public class StudiosController : BaseJellyfinApiController .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - User? user = userId.Value.Equals(default) + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -144,7 +145,7 @@ public class StudiosController : BaseJellyfinApiController var dtoOptions = new DtoOptions().AddClientFields(User); var item = _libraryManager.GetStudio(name); - if (!userId.Equals(default)) + if (!userId.IsNullOrEmpty()) { var user = _userManager.GetUserById(userId.Value); diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs index 675757fc51..2aa6d25a79 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -62,7 +63,7 @@ public class SuggestionsController : BaseJellyfinApiController [FromQuery] int? limit, [FromQuery] bool enableTotalRecordCount = false) { - var user = userId.Equals(default) + var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId); diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 55a30d4692..3d84b61bf4 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -111,7 +111,7 @@ public class TvShowsController : BaseJellyfinApiController }, options); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -150,7 +150,7 @@ public class TvShowsController : BaseJellyfinApiController [FromQuery] bool? enableUserData) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -222,7 +222,7 @@ public class TvShowsController : BaseJellyfinApiController [FromQuery] ItemSortBy? sortBy) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -284,7 +284,7 @@ public class TvShowsController : BaseJellyfinApiController } // This must be the last filter - if (adjacentTo.HasValue && !adjacentTo.Value.Equals(default)) + if (!adjacentTo.IsNullOrEmpty()) { episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo.Value).ToList(); } @@ -339,7 +339,7 @@ public class TvShowsController : BaseJellyfinApiController [FromQuery] bool? enableUserData) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index f9f27f1480..ea10ee24f9 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -8,6 +8,7 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.UserDtos; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; @@ -532,7 +533,7 @@ public class UserController : BaseJellyfinApiController public ActionResult GetCurrentUser() { var userId = User.GetUserId(); - if (userId.Equals(default)) + if (userId.IsEmpty()) { return BadRequest(); } diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index 2c4fe91862..264e0a3dbd 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -8,6 +8,7 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -84,7 +85,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - var item = itemId.Equals(default) + var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); @@ -145,7 +146,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - var item = itemId.Equals(default) + var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); @@ -185,7 +186,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - var item = itemId.Equals(default) + var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); @@ -221,7 +222,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - var item = itemId.Equals(default) + var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); @@ -257,7 +258,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - var item = itemId.Equals(default) + var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); @@ -294,7 +295,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - var item = itemId.Equals(default) + var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); @@ -330,7 +331,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - var item = itemId.Equals(default) + var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); @@ -375,7 +376,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - var item = itemId.Equals(default) + var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); @@ -558,7 +559,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - var item = itemId.Equals(default) + var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index c231c147fc..e6c3198698 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -11,6 +11,7 @@ using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; +using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; @@ -96,12 +97,12 @@ public class VideosController : BaseJellyfinApiController public ActionResult> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - var user = userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - var item = itemId.Equals(default) - ? (userId.Value.Equals(default) + var item = itemId.IsEmpty() + ? (userId.IsNullOrEmpty() ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) : _libraryManager.GetItemById(itemId); diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs index ca46c38c59..e4aa0ea42d 100644 --- a/Jellyfin.Api/Controllers/YearsController.cs +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -90,7 +90,7 @@ public class YearsController : BaseJellyfinApiController .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - User? user = userId.Value.Equals(default) + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); @@ -110,7 +110,7 @@ public class YearsController : BaseJellyfinApiController { var folder = (Folder)parentItem; - if (userId.Equals(default)) + if (userId.IsNullOrEmpty()) { items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList(); } @@ -182,7 +182,7 @@ public class YearsController : BaseJellyfinApiController var dtoOptions = new DtoOptions() .AddClientFields(User); - if (!userId.Value.Equals(default)) + if (!userId.IsNullOrEmpty()) { var user = _userManager.GetUserById(userId.Value); return _dtoService.GetBaseItemDto(item, dtoOptions, user); diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 321987ca74..6a24ad32ab 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; @@ -86,7 +87,7 @@ public class MediaInfoHelper string? mediaSourceId = null, string? liveStreamId = null) { - var user = userId is null || userId.Value.Equals(default) + var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); var item = _libraryManager.GetItemById(id); diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index be3d4dfb67..429e972132 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -7,6 +7,7 @@ using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -67,7 +68,7 @@ public static class RequestHelpers var authenticatedUserId = claimsPrincipal.GetUserId(); // UserId not provided, fall back to authenticated user id. - if (userId is null || userId.Value.Equals(default)) + if (userId.IsNullOrEmpty()) { return authenticatedUserId; } diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 78943f7b58..7a3842a9f7 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -82,7 +82,7 @@ public static class StreamingHelpers }; var userId = httpContext.User.GetUserId(); - if (!userId.Equals(default)) + if (!userId.IsEmpty()) { state.User = userManager.GetUserById(userId); } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index cc464a7a3d..c4a2bfdb87 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -11,6 +11,7 @@ using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Data.Events.Users; +using Jellyfin.Extensions; using MediaBrowser.Common; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; @@ -117,7 +118,7 @@ namespace Jellyfin.Server.Implementations.Users /// public User? GetUserById(Guid id) { - if (id.Equals(default)) + if (id.IsEmpty()) { throw new ArgumentException("Guid can't be empty", nameof(id)); } diff --git a/MediaBrowser.Controller/Entities/AggregateFolder.cs b/MediaBrowser.Controller/Entities/AggregateFolder.cs index d789033f16..b225f22df0 100644 --- a/MediaBrowser.Controller/Entities/AggregateFolder.cs +++ b/MediaBrowser.Controller/Entities/AggregateFolder.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; @@ -184,7 +185,7 @@ namespace MediaBrowser.Controller.Entities /// The id is empty. public BaseItem FindVirtualChild(Guid id) { - if (id.Equals(default)) + if (id.IsEmpty()) { throw new ArgumentNullException(nameof(id)); } diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index 18d948a62f..11cdf84445 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -24,7 +24,7 @@ namespace MediaBrowser.Controller.Entities.Audio public class MusicArtist : Folder, IItemByName, IHasMusicGenres, IHasDualAccess, IHasLookupInfo { [JsonIgnore] - public bool IsAccessedByName => ParentId.Equals(default); + public bool IsAccessedByName => ParentId.IsEmpty(); [JsonIgnore] public override bool IsFolder => !IsAccessedByName; diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index fdbceac961..ddcc994a0b 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -240,7 +240,7 @@ namespace MediaBrowser.Controller.Entities { get { - if (!ChannelId.Equals(default)) + if (!ChannelId.IsEmpty()) { return SourceType.Channel; } @@ -530,7 +530,7 @@ namespace MediaBrowser.Controller.Entities get { var id = DisplayParentId; - if (id.Equals(default)) + if (id.IsEmpty()) { return null; } @@ -746,7 +746,7 @@ namespace MediaBrowser.Controller.Entities public virtual bool StopRefreshIfLocalMetadataFound => true; [JsonIgnore] - protected virtual bool SupportsOwnedItems => !ParentId.Equals(default) && IsFileProtocol; + protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol; [JsonIgnore] public virtual bool SupportsPeople => false; @@ -823,7 +823,7 @@ namespace MediaBrowser.Controller.Entities public BaseItem GetOwner() { var ownerId = OwnerId; - return ownerId.Equals(default) ? null : LibraryManager.GetItemById(ownerId); + return ownerId.IsEmpty() ? null : LibraryManager.GetItemById(ownerId); } public bool CanDelete(User user, List allCollectionFolders) @@ -968,7 +968,7 @@ namespace MediaBrowser.Controller.Entities public BaseItem GetParent() { var parentId = ParentId; - if (parentId.Equals(default)) + if (parentId.IsEmpty()) { return null; } @@ -1361,7 +1361,7 @@ namespace MediaBrowser.Controller.Entities var tasks = extras.Select(i => { var subOptions = new MetadataRefreshOptions(options); - if (!i.OwnerId.Equals(ownerId) || !i.ParentId.Equals(default)) + if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty()) { i.OwnerId = ownerId; i.ParentId = Guid.Empty; @@ -1673,7 +1673,7 @@ namespace MediaBrowser.Controller.Entities // First get using the cached Id if (info.ItemId.HasValue) { - if (info.ItemId.Value.Equals(default)) + if (info.ItemId.Value.IsEmpty()) { return null; } @@ -2439,7 +2439,7 @@ namespace MediaBrowser.Controller.Entities return Task.FromResult(true); } - if (video.OwnerId.Equals(default)) + if (video.OwnerId.IsEmpty()) { video.OwnerId = this.Id; } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index e707eedbf6..74eb089de3 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using System.Threading.Tasks.Dataflow; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Collections; @@ -198,7 +199,7 @@ namespace MediaBrowser.Controller.Entities { item.SetParent(this); - if (item.Id.Equals(default)) + if (item.Id.IsEmpty()) { item.Id = LibraryManager.GetNewItemId(item.Path, item.GetType()); } @@ -697,7 +698,7 @@ namespace MediaBrowser.Controller.Entities if (this is not UserRootFolder && this is not AggregateFolder - && query.ParentId.Equals(default)) + && query.ParentId.IsEmpty()) { query.Parent = this; } @@ -840,7 +841,7 @@ namespace MediaBrowser.Controller.Entities return true; } - if (query.AdjacentTo.HasValue && !query.AdjacentTo.Value.Equals(default)) + if (!query.AdjacentTo.IsNullOrEmpty()) { Logger.LogDebug("Query requires post-filtering due to AdjacentTo"); return true; @@ -987,7 +988,7 @@ namespace MediaBrowser.Controller.Entities #pragma warning restore CA1309 // This must be the last filter - if (query.AdjacentTo.HasValue && !query.AdjacentTo.Value.Equals(default)) + if (!query.AdjacentTo.IsNullOrEmpty()) { items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); } diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index bf31508c1d..37e2414142 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -74,12 +75,12 @@ namespace MediaBrowser.Controller.Entities.TV get { var seriesId = SeriesId; - if (seriesId.Equals(default)) + if (seriesId.IsEmpty()) { seriesId = FindSeriesId(); } - return seriesId.Equals(default) ? null : (LibraryManager.GetItemById(seriesId) as Series); + return seriesId.IsEmpty() ? null : (LibraryManager.GetItemById(seriesId) as Series); } } @@ -89,12 +90,12 @@ namespace MediaBrowser.Controller.Entities.TV get { var seasonId = SeasonId; - if (seasonId.Equals(default)) + if (seasonId.IsEmpty()) { seasonId = FindSeasonId(); } - return seasonId.Equals(default) ? null : (LibraryManager.GetItemById(seasonId) as Season); + return seasonId.IsEmpty() ? null : (LibraryManager.GetItemById(seasonId) as Season); } } @@ -271,7 +272,7 @@ namespace MediaBrowser.Controller.Entities.TV var seasonId = SeasonId; - if (!seasonId.Equals(default) && !list.Contains(seasonId)) + if (!seasonId.IsEmpty() && !list.Contains(seasonId)) { list.Add(seasonId); } diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index 0a040a3c28..c29cefc15e 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Querying; @@ -48,12 +49,12 @@ namespace MediaBrowser.Controller.Entities.TV get { var seriesId = SeriesId; - if (seriesId.Equals(default)) + if (seriesId.IsEmpty()) { seriesId = FindSeriesId(); } - return seriesId.Equals(default) ? null : (LibraryManager.GetItemById(seriesId) as Series); + return seriesId.IsEmpty() ? null : (LibraryManager.GetItemById(seriesId) as Series); } } diff --git a/MediaBrowser.Controller/Entities/UserView.cs b/MediaBrowser.Controller/Entities/UserView.cs index eb026deb4f..c93488a85e 100644 --- a/MediaBrowser.Controller/Entities/UserView.cs +++ b/MediaBrowser.Controller/Entities/UserView.cs @@ -70,11 +70,11 @@ namespace MediaBrowser.Controller.Entities /// public override IEnumerable GetIdsForAncestorQuery() { - if (!DisplayParentId.Equals(default)) + if (!DisplayParentId.IsEmpty()) { yield return DisplayParentId; } - else if (!ParentId.Equals(default)) + else if (!ParentId.IsEmpty()) { yield return ParentId; } @@ -95,11 +95,11 @@ namespace MediaBrowser.Controller.Entities { var parent = this as Folder; - if (!DisplayParentId.Equals(default)) + if (!DisplayParentId.IsEmpty()) { parent = LibraryManager.GetItemById(DisplayParentId) as Folder ?? parent; } - else if (!ParentId.Equals(default)) + else if (!ParentId.IsEmpty()) { parent = LibraryManager.GetItemById(ParentId) as Folder ?? parent; } diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index a3525c8623..4af000557e 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -433,7 +433,7 @@ namespace MediaBrowser.Controller.Entities var user = query.User; // This must be the last filter - if (query.AdjacentTo.HasValue && !query.AdjacentTo.Value.Equals(default)) + if (!query.AdjacentTo.IsNullOrEmpty()) { items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); } diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index be2eb4d288..5adadec390 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -456,7 +456,7 @@ namespace MediaBrowser.Controller.Entities foreach (var child in LinkedAlternateVersions) { // Reset the cached value - if (child.ItemId.HasValue && child.ItemId.Value.Equals(default)) + if (child.ItemId.IsNullOrEmpty()) { child.ItemId = null; } diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index 483d0a1d82..d79e4441aa 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -9,6 +9,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -400,7 +401,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable if (state.VideoRequest is not null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { - var user = userId.Equals(default) ? null : _userManager.GetUserById(userId); + var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId); if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) { this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index bf18d46dcd..da683a17e6 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; @@ -1536,7 +1537,7 @@ namespace MediaBrowser.Model.Dlna private static void ValidateMediaOptions(MediaOptions options, bool isMediaSource) { - if (options.ItemId.Equals(default)) + if (options.ItemId.IsEmpty()) { ArgumentException.ThrowIfNullOrEmpty(options.DeviceId); } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 4ba8844182..b530b9de3f 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -706,7 +706,7 @@ namespace MediaBrowser.Providers.Manager { BaseItem? referenceItem = null; - if (!searchInfo.ItemId.Equals(default)) + if (!searchInfo.ItemId.IsEmpty()) { referenceItem = _libraryManager.GetItemById(searchInfo.ItemId); } @@ -944,7 +944,7 @@ namespace MediaBrowser.Providers.Manager public void QueueRefresh(Guid itemId, MetadataRefreshOptions options, RefreshPriority priority) { ArgumentNullException.ThrowIfNull(itemId); - if (itemId.Equals(default)) + if (itemId.IsEmpty()) { throw new ArgumentException("Guid can't be empty", nameof(itemId)); } diff --git a/src/Jellyfin.Extensions/GuidExtensions.cs b/src/Jellyfin.Extensions/GuidExtensions.cs new file mode 100644 index 0000000000..95c591a82e --- /dev/null +++ b/src/Jellyfin.Extensions/GuidExtensions.cs @@ -0,0 +1,26 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Jellyfin.Extensions; + +/// +/// Guid specific extensions. +/// +public static class GuidExtensions +{ + /// + /// Determine whether the guid is default. + /// + /// The guid. + /// Whether the guid is the default value. + public static bool IsEmpty(this Guid guid) + => guid.Equals(default); + + /// + /// Determine whether the guid is null or default. + /// + /// The guid. + /// Whether the guid is null or the default valueF. + public static bool IsNullOrEmpty([NotNullWhen(false)] this Guid? guid) + => guid is null || guid.Value.IsEmpty(); +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs index 656e3c3dab..0a50b5c3b9 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs @@ -18,7 +18,7 @@ namespace Jellyfin.Extensions.Json.Converters { // null got handled higher up the call stack var val = value!.Value; - if (val.Equals(default)) + if (val.IsEmpty()) { writer.WriteNullValue(); } diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index 51abb503eb..bc968f8ee1 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -150,7 +150,7 @@ namespace Jellyfin.LiveTv.Channels /// public async Task> GetChannelsInternalAsync(ChannelQuery query) { - var user = query.UserId.Equals(default) + var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId); @@ -263,7 +263,7 @@ namespace Jellyfin.LiveTv.Channels /// public async Task> GetChannelsAsync(ChannelQuery query) { - var user = query.UserId.Equals(default) + var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId); @@ -695,7 +695,7 @@ namespace Jellyfin.LiveTv.Channels // Find the corresponding channel provider plugin var channelProvider = GetChannelProvider(channel); - var parentItem = query.ParentId.Equals(default) + var parentItem = query.ParentId.IsEmpty() ? channel : _libraryManager.GetItemById(query.ParentId); @@ -708,7 +708,7 @@ namespace Jellyfin.LiveTv.Channels cancellationToken) .ConfigureAwait(false); - if (query.ParentId.Equals(default)) + if (query.ParentId.IsEmpty()) { query.Parent = channel; } diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index 9eb3aa2fd6..e7e927b2d0 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -1992,7 +1992,7 @@ namespace Jellyfin.LiveTv.EmbyTV await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false); } - var people = item.Id.Equals(default) ? new List() : _libraryManager.GetPeople(item); + var people = item.Id.IsEmpty() ? new List() : _libraryManager.GetPeople(item); var directors = people .Where(i => i.IsType(PersonKind.Director)) @@ -2317,7 +2317,7 @@ namespace Jellyfin.LiveTv.EmbyTV { string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId; - if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.Equals(default)) + if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.IsEmpty()) { if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel)) { @@ -2376,7 +2376,7 @@ namespace Jellyfin.LiveTv.EmbyTV { string channelId = null; - if (!programInfo.ChannelId.Equals(default)) + if (!programInfo.ChannelId.IsEmpty()) { if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel)) { diff --git a/src/Jellyfin.LiveTv/LiveTvDtoService.cs b/src/Jellyfin.LiveTv/LiveTvDtoService.cs index 7c7c26eb4a..55b056d3d8 100644 --- a/src/Jellyfin.LiveTv/LiveTvDtoService.cs +++ b/src/Jellyfin.LiveTv/LiveTvDtoService.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Drawing; @@ -456,7 +457,7 @@ namespace Jellyfin.LiveTv info.Id = timer.ExternalId; } - if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) + if (!dto.ChannelId.IsEmpty() && string.IsNullOrEmpty(info.ChannelId)) { var channel = _libraryManager.GetItemById(dto.ChannelId); @@ -522,7 +523,7 @@ namespace Jellyfin.LiveTv info.Id = timer.ExternalId; } - if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) + if (!dto.ChannelId.IsEmpty() && string.IsNullOrEmpty(info.ChannelId)) { var channel = _libraryManager.GetItemById(dto.ChannelId); diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 71822f3762..bada4249aa 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; +using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; @@ -150,7 +151,7 @@ namespace Jellyfin.LiveTv public QueryResult GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken) { - var user = query.UserId.Equals(default) + var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId); @@ -1245,7 +1246,7 @@ namespace Jellyfin.LiveTv { cancellationToken.ThrowIfCancellationRequested(); - if (itemId.Equals(default)) + if (itemId.IsEmpty()) { // Somehow some invalid data got into the db. It probably predates the boundary checking continue; @@ -1504,7 +1505,7 @@ namespace Jellyfin.LiveTv public async Task> GetRecordingsAsync(RecordingQuery query, DtoOptions options) { - var user = query.UserId.Equals(default) + var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId); diff --git a/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs b/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs index 4e8aec9f19..2f21495044 100644 --- a/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs +++ b/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Api.Models.StartupDtos; using Jellyfin.Api.Models.UserDtos; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Model.Dto; using Xunit; @@ -56,7 +57,7 @@ namespace Jellyfin.Server.Integration.Tests public static async Task GetRootFolderDtoAsync(HttpClient client, Guid userId = default) { - if (userId.Equals(default)) + if (userId.IsEmpty()) { var userDto = await GetUserDtoAsync(client); userId = userDto.Id; -- cgit v1.2.3 From 4962eb5b432c28bc829d915c29becc57db20cf45 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Jan 2024 17:48:56 -0700 Subject: chore(deps): update github/codeql-action action to v3.23.1 (#10880) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci-codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 4b2673e82d..d8c550e704 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@e5f05b81d5b6ff8cfa111c80c22c5fd02a384118 # v3.23.0 + uses: github/codeql-action/init@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@e5f05b81d5b6ff8cfa111c80c22c5fd02a384118 # v3.23.0 + uses: github/codeql-action/autobuild@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e5f05b81d5b6ff8cfa111c80c22c5fd02a384118 # v3.23.0 + uses: github/codeql-action/analyze@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 -- cgit v1.2.3 From a884b1f7869436fb373d71106330ab1db79cee11 Mon Sep 17 00:00:00 2001 From: Gauvino <68083474+Gauvino@users.noreply.github.com> Date: Thu, 18 Jan 2024 02:11:03 +0100 Subject: Refactor Dockerfile and build (#10603) * Fix fedora * Fix RID Linux * Fix package and image versions * Fix buildling and optimize docker images ``` * Removed find obj * Changed curl command and added gpg * Added to Contributors * Removed apt-transport-https package * Removed RASPI * Update Intel drivers version * Update Dockerfile for CentOS, Fedora, and portable deployments - Changed Jammy docker image to Built-in Jammy Microsoft .NET SDK image - Switched from using "Yum" to "Dnf" for CentOS and Fedora - Added "dnf clean all" and "rm -rf /var/cache/dnf" to the end of CentOS and Fedora Dockerfiles - Added "apt-get clean", "apt-get autoremove", "rm -rf /var/lib/apt/lists/*" to the end of the Debian/Ubuntu Dockerfiles - Added ${DOTNET_VERSION} in every Dockerfile except CentOS/Fedora - Removed previous warning comment for dotnet publish build in parallel - Arranged package installation * Re-arranged Dockerfile package installation * Re-align * Remove curl * Remove curl --- CONTRIBUTORS.md | 3 +- Dockerfile | 66 +++++++++++++--------------- Dockerfile.arm | 56 ++++++++++------------- Dockerfile.arm64 | 54 +++++++++++------------ bump_version | 4 +- debian/rules | 12 ++--- deployment/Dockerfile.centos.amd64 | 29 +++++++----- deployment/Dockerfile.debian.amd64 | 17 ++++--- deployment/Dockerfile.debian.arm64 | 25 +++++++---- deployment/Dockerfile.debian.armhf | 25 +++++++---- deployment/Dockerfile.docker.amd64 | 9 ++-- deployment/Dockerfile.docker.arm64 | 9 ++-- deployment/Dockerfile.docker.armhf | 9 ++-- deployment/Dockerfile.fedora.amd64 | 25 +++++++---- deployment/Dockerfile.linux.amd64 | 13 ++++-- deployment/Dockerfile.linux.amd64-musl | 13 ++++-- deployment/Dockerfile.linux.arm64 | 13 ++++-- deployment/Dockerfile.linux.armhf | 13 ++++-- deployment/Dockerfile.linux.musl-linux-arm64 | 15 +++++-- deployment/Dockerfile.macos.amd64 | 13 ++++-- deployment/Dockerfile.macos.arm64 | 13 ++++-- deployment/Dockerfile.portable | 13 ++++-- deployment/Dockerfile.ubuntu.amd64 | 19 ++++---- deployment/Dockerfile.ubuntu.arm64 | 55 +++++++++++------------ deployment/Dockerfile.ubuntu.armhf | 55 +++++++++++------------ deployment/Dockerfile.windows.amd64 | 13 ++++-- deployment/build.centos.amd64 | 6 +-- deployment/build.debian.amd64 | 2 +- deployment/build.debian.arm64 | 2 +- deployment/build.debian.armhf | 2 +- deployment/build.fedora.amd64 | 4 +- deployment/build.linux.amd64 | 8 ++-- deployment/build.linux.amd64-musl | 8 ++-- deployment/build.linux.arm64 | 8 ++-- deployment/build.linux.armhf | 8 ++-- deployment/build.linux.musl-linux-arm64 | 8 ++-- deployment/build.macos.amd64 | 8 ++-- deployment/build.macos.arm64 | 8 ++-- deployment/build.portable | 8 ++-- deployment/build.ubuntu.amd64 | 2 +- deployment/build.ubuntu.arm64 | 2 +- deployment/build.ubuntu.armhf | 2 +- deployment/build.windows.amd64 | 22 +++++----- fedora/README.md | 12 +++-- fedora/jellyfin.spec | 16 +------ 45 files changed, 401 insertions(+), 326 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4e45fd24ad..457f59e0f6 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -172,9 +172,10 @@ - [tallbl0nde](https://github.com/tallbl0nde) - [sleepycatcoding](https://github.com/sleepycatcoding) - [scampower3](https://github.com/scampower3) - - [Chris-Codes-It] (https://github.com/Chris-Codes-It) + - [Chris-Codes-It](https://github.com/Chris-Codes-It) - [Pithaya](https://github.com/Pithaya) - [Çağrı Sakaoğlu](https://github.com/ilovepilav) + - [Gauvino](https://github.com/Gauvino) # Emby Contributors diff --git a/Dockerfile b/Dockerfile index d3f10cd12e..550c3203d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,72 +8,68 @@ FROM node:20-alpine as web-builder ARG JELLYFIN_WEB_VERSION=master RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \ && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ + && apk del curl \ && cd jellyfin-web-* \ && npm ci --no-audit --unsafe-perm \ && npm run build:production \ && mv dist /dist -FROM debian:stable-slim as app +FROM debian:bookworm-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" # http://stackoverflow.com/questions/48162574/ddg#49462622 ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn # https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support) +ENV NVIDIA_VISIBLE_DEVICES="all" ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" +ENV JELLYFIN_DATA_DIR=/config +ENV JELLYFIN_CACHE_DIR=/cache + # https://github.com/intel/compute-runtime/releases -ARG GMMLIB_VERSION=22.0.2 -ARG IGC_VERSION=1.0.10395 -ARG NEO_VERSION=22.08.22549 -ARG LEVEL_ZERO_VERSION=1.3.22549 +ARG GMMLIB_VERSION=22.3.11.ci17757293 +ARG IGC_VERSION=1.0.15136.22 +ARG NEO_VERSION=23.39.27427.23 +ARG LEVEL_ZERO_VERSION=1.3.27427.23 -# Install dependencies: -# mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding. -# curl: healthcheck RUN apt-get update \ - && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget curl \ - && wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \ + && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg curl \ + && curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | gpg --dearmor -o /etc/apt/trusted.gpg.d/debian-jellyfin.gpg \ && echo "deb [arch=$( dpkg --print-architecture )] https://repo.jellyfin.org/$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release ) $( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release ) main" | tee /etc/apt/sources.list.d/jellyfin.list \ && apt-get update \ - && apt-get install --no-install-recommends --no-install-suggests -y \ - mesa-va-drivers \ - jellyfin-ffmpeg5 \ - openssl \ - locales \ + && apt-get install --no-install-recommends --no-install-suggests -y mesa-va-drivers jellyfin-ffmpeg6 openssl locales \ # Intel VAAPI Tone mapping dependencies: # Prefer NEO to Beignet since the latter one doesn't support Comet Lake or newer for now. # Do not use the intel-opencl-icd package from repo since they will not build with RELEASE_WITH_REGKEYS enabled. && mkdir intel-compute-runtime \ && cd intel-compute-runtime \ - && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-gmmlib_${GMMLIB_VERSION}_amd64.deb \ - && wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-core_${IGC_VERSION}_amd64.deb \ - && wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-opencl_${IGC_VERSION}_amd64.deb \ - && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-opencl-icd_${NEO_VERSION}_amd64.deb \ - && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-level-zero-gpu_${LEVEL_ZERO_VERSION}_amd64.deb \ + && curl -LO https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-core_${IGC_VERSION}_amd64.deb \ + -LO https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-opencl_${IGC_VERSION}_amd64.deb \ + -LO https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-level-zero-gpu_${LEVEL_ZERO_VERSION}_amd64.deb \ + -LO https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-opencl-icd_${NEO_VERSION}_amd64.deb \ + -LO https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/libigdgmm12_${GMMLIB_VERSION}_amd64.deb \ && dpkg -i *.deb \ && cd .. \ && rm -rf intel-compute-runtime \ - && apt-get remove gnupg wget -y \ + && apt-get remove gnupg -y \ && apt-get clean autoclean -y \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /cache /config /media \ - && chmod 777 /cache /config /media \ + && mkdir -p ${JELLYFIN_DATA_DIR} ${JELLYFIN_CACHE_DIR} \ + && chmod 777 ${JELLYFIN_DATA_DIR} ${JELLYFIN_CACHE_DIR} \ && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen -# ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 -ENV LC_ALL en_US.UTF-8 -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US:en +ENV LC_ALL=en_US.UTF-8 +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder WORKDIR /repo COPY . . ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# because of changes in docker and systemd we need to not build in parallel at the moment -# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting -RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="/jellyfin" --self-contained --runtime linux-x64 -p:DebugSymbols=false -p:DebugType=none + +RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-x64 -p:DebugSymbols=false -p:DebugType=none FROM app @@ -83,11 +79,9 @@ COPY --from=builder /jellyfin /jellyfin COPY --from=web-builder /dist /jellyfin/jellyfin-web EXPOSE 8096 -VOLUME /cache /config -ENTRYPOINT ["./jellyfin/jellyfin", \ - "--datadir", "/config", \ - "--cachedir", "/cache", \ - "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"] +VOLUME ${JELLYFIN_DATA_DIR} ${JELLYFIN_CACHE_DIR} +ENTRYPOINT [ "./jellyfin/jellyfin", \ + "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg" ] HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ - CMD curl -Lk -fsS "${HEALTHCHECK_URL}" || exit 1 + CMD curl -Lk -fsS "${HEALTHCHECK_URL}" || exit 1 diff --git a/Dockerfile.arm b/Dockerfile.arm index db1acc838a..07039e43b5 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -4,64 +4,58 @@ # https://github.com/multiarch/qemu-user-static#binfmt_misc-register ARG DOTNET_VERSION=8.0 - FROM node:20-alpine as web-builder ARG JELLYFIN_WEB_VERSION=master RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \ && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ + && apk del curl \ && cd jellyfin-web-* \ && npm ci --no-audit --unsafe-perm \ && npm run build:production \ && mv dist /dist FROM multiarch/qemu-user-static:x86_64-arm as qemu -FROM arm32v7/debian:stable-slim as app +FROM arm32v7/debian:bookworm-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" # http://stackoverflow.com/questions/48162574/ddg#49462622 ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn # https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support) +ENV NVIDIA_VISIBLE_DEVICES="all" ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" +ENV JELLYFIN_DATA_DIR=/config +ENV JELLYFIN_CACHE_DIR=/cache + COPY --from=qemu /usr/bin/qemu-arm-static /usr/bin -# curl: setup & healthcheck RUN apt-get update \ - && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg curl && \ - curl -ks https://repo.jellyfin.org/debian/jellyfin_team.gpg.key | apt-key add - && \ - curl -ks https://keyserver.ubuntu.com/pks/lookup?op=get\&search=0x6587ffd6536b8826e88a62547876ae518cbcf2f2 | apt-key add - && \ - echo 'deb [arch=armhf] https://repo.jellyfin.org/debian buster main' > /etc/apt/sources.list.d/jellyfin.list && \ - echo "deb http://ppa.launchpad.net/ubuntu-raspi2/ppa/ubuntu bionic main">> /etc/apt/sources.list.d/raspbins.list && \ - apt-get update && \ - apt-get install --no-install-recommends --no-install-suggests -y \ - jellyfin-ffmpeg \ - libssl-dev \ - libfontconfig1 \ - libfreetype6 \ - vainfo \ - libva2 \ - locales \ + && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg curl \ + && curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | gpg --dearmor -o /etc/apt/trusted.gpg.d/debian-jellyfin.gpg \ + && curl -fsSL https://keyserver.ubuntu.com/pks/lookup?op=get\&search=0x6587ffd6536b8826e88a62547876ae518cbcf2f2 | gpg --dearmor -o /etc/apt/trusted.gpg.d/ubuntu-jellyfin.gpg \ + && echo "deb [arch=$( dpkg --print-architecture )] https://repo.jellyfin.org/$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release ) $( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release ) main" | tee /etc/apt/sources.list.d/jellyfin.list \ + && apt-get update \ + && apt-get install --no-install-recommends --no-install-suggests -y \ + jellyfin-ffmpeg6 libssl-dev libfontconfig1 \ + libfreetype6 vainfo libva2 locales \ && apt-get remove gnupg -y \ && apt-get clean autoclean -y \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /cache /config /media \ - && chmod 777 /cache /config /media \ + && mkdir -p ${JELLYFIN_DATA_DIR} ${JELLYFIN_CACHE_DIR} \ + && chmod 777 ${JELLYFIN_DATA_DIR} ${JELLYFIN_CACHE_DIR} \ && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen -# ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 -ENV LC_ALL en_US.UTF-8 -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US:en +ENV LC_ALL=en_US.UTF-8 +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder WORKDIR /repo COPY . . ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# Discard objs - may cause failures if exists -RUN find . -type d -name obj | xargs -r rm -r -# Build + RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm -p:DebugSymbols=false -p:DebugType=none FROM app @@ -72,11 +66,9 @@ COPY --from=builder /jellyfin /jellyfin COPY --from=web-builder /dist /jellyfin/jellyfin-web EXPOSE 8096 -VOLUME /cache /config -ENTRYPOINT ["./jellyfin/jellyfin", \ - "--datadir", "/config", \ - "--cachedir", "/cache", \ - "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"] +VOLUME ${JELLYFIN_DATA_DIR} ${JELLYFIN_CACHE_DIR} +ENTRYPOINT [ "/jellyfin/jellyfin", \ + "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg" ] HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ - CMD curl -Lk -fsS "${HEALTHCHECK_URL}" || exit 1 + CMD curl -Lk -fsS "${HEALTHCHECK_URL}" || exit 1 diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index 3eb5f45fc4..54023794fc 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -4,58 +4,58 @@ # https://github.com/multiarch/qemu-user-static#binfmt_misc-register ARG DOTNET_VERSION=8.0 - FROM node:20-alpine as web-builder ARG JELLYFIN_WEB_VERSION=master RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \ && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ + && apk del curl \ && cd jellyfin-web-* \ && npm ci --no-audit --unsafe-perm \ && npm run build:production \ && mv dist /dist FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu -FROM arm64v8/debian:stable-slim as app +FROM arm64v8/debian:bookworm-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" # http://stackoverflow.com/questions/48162574/ddg#49462622 ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn # https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support) +ENV NVIDIA_VISIBLE_DEVICES="all" ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" +ENV JELLYFIN_DATA_DIR=/config +ENV JELLYFIN_CACHE_DIR=/cache + COPY --from=qemu /usr/bin/qemu-aarch64-static /usr/bin -# curl: healcheck -RUN apt-get update && apt-get install --no-install-recommends --no-install-suggests -y \ - ffmpeg \ - libssl-dev \ - ca-certificates \ - libfontconfig1 \ - libfreetype6 \ - libomxil-bellagio0 \ - libomxil-bellagio-bin \ - locales \ - curl \ +RUN apt-get update \ + && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg curl \ + && curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | gpg --dearmor -o /etc/apt/trusted.gpg.d/debian-jellyfin.gpg \ + && curl -fsSL https://keyserver.ubuntu.com/pks/lookup?op=get\&search=0x6587ffd6536b8826e88a62547876ae518cbcf2f2 | gpg --dearmor -o /etc/apt/trusted.gpg.d/ubuntu-jellyfin.gpg \ + && echo "deb [arch=$( dpkg --print-architecture )] https://repo.jellyfin.org/$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release ) $( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release ) main" | tee /etc/apt/sources.list.d/jellyfin.list \ + && apt-get update \ + && apt-get install --no-install-recommends --no-install-suggests -y \ + jellyfin-ffmpeg6 locales libssl-dev libfontconfig1 \ + libfreetype6 libomxil-bellagio0 libomxil-bellagio-bin \ + && apt-get remove gnupg -y \ && apt-get clean autoclean -y \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /cache /config /media \ - && chmod 777 /cache /config /media \ + && mkdir -p ${JELLYFIN_DATA_DIR} ${JELLYFIN_CACHE_DIR} \ + && chmod 777 ${JELLYFIN_DATA_DIR} ${JELLYFIN_CACHE_DIR} \ && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen -# ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 -ENV LC_ALL en_US.UTF-8 -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US:en +ENV LC_ALL=en_US.UTF-8 +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder WORKDIR /repo COPY . . ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# Discard objs - may cause failures if exists -RUN find . -type d -name obj | xargs -r rm -r -# Build + RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm64 -p:DebugSymbols=false -p:DebugType=none FROM app @@ -66,11 +66,9 @@ COPY --from=builder /jellyfin /jellyfin COPY --from=web-builder /dist /jellyfin/jellyfin-web EXPOSE 8096 -VOLUME /cache /config -ENTRYPOINT ["./jellyfin/jellyfin", \ - "--datadir", "/config", \ - "--cachedir", "/cache", \ - "--ffmpeg", "/usr/bin/ffmpeg"] +VOLUME ${JELLYFIN_DATA_DIR} ${JELLYFIN_CACHE_DIR} +ENTRYPOINT [ "/jellyfin/jellyfin", \ + "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg" ] HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ - CMD curl -Lk -fsS "${HEALTHCHECK_URL}" || exit 1 + CMD curl -Lk -fsS "${HEALTHCHECK_URL}" || exit 1 diff --git a/bump_version b/bump_version index 41d27f5c8a..dd55e62c79 100755 --- a/bump_version +++ b/bump_version @@ -21,7 +21,7 @@ fi shared_version_file="./SharedVersion.cs" build_file="./build.yaml" # csproj files for nuget packages -jellyfin_subprojects=( +jellyfin_subprojects=( MediaBrowser.Common/MediaBrowser.Common.csproj Jellyfin.Data/Jellyfin.Data.csproj MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -97,7 +97,7 @@ cat ${debian_changelog_file} >> ${debian_changelog_temp} # Move into place mv ${debian_changelog_temp} ${debian_changelog_file} -# Write out a temporary Yum changelog with our new stuff prepended and some templated formatting +# Write out a temporary Dnf changelog with our new stuff prepended and some templated formatting fedora_spec_file="fedora/jellyfin.spec" fedora_changelog_temp="$( mktemp )" fedora_spec_temp_dir="$( mktemp -d )" diff --git a/debian/rules b/debian/rules index 069d48aad1..79cd55a15d 100755 --- a/debian/rules +++ b/debian/rules @@ -7,27 +7,27 @@ HOST_ARCH := $(shell arch) BUILD_ARCH := ${DEB_HOST_MULTIARCH} ifeq ($(HOST_ARCH),x86_64) # Building AMD64 - DOTNETRUNTIME := debian-x64 + DOTNETRUNTIME := linux-x64 ifeq ($(BUILD_ARCH),arm-linux-gnueabihf) # Cross-building ARM on AMD64 - DOTNETRUNTIME := debian-arm + DOTNETRUNTIME := linux-arm endif ifeq ($(BUILD_ARCH),aarch64-linux-gnu) # Cross-building ARM on AMD64 - DOTNETRUNTIME := debian-arm64 + DOTNETRUNTIME := linux-arm64 endif endif ifeq ($(HOST_ARCH),armv7l) # Building ARM - DOTNETRUNTIME := debian-arm + DOTNETRUNTIME := linux-arm endif ifeq ($(HOST_ARCH),arm64) # Building ARM - DOTNETRUNTIME := debian-arm64 + DOTNETRUNTIME := linux-arm64 endif ifeq ($(HOST_ARCH),aarch64) # Building ARM - DOTNETRUNTIME := debian-arm64 + DOTNETRUNTIME := linux-arm64 endif export DH_VERBOSE=1 diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index 7c9bbf39e9..3db184f494 100644 --- a/deployment/Dockerfile.centos.amd64 +++ b/deployment/Dockerfile.centos.amd64 @@ -1,29 +1,36 @@ -FROM centos:7 +FROM quay.io/centos/centos:stream9 + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist ENV IS_DOCKER=YES # Prepare CentOS environment -RUN yum update -yq \ - && yum install -yq epel-release \ - && yum install -yq @buildsys-build rpmdevtools yum-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel git wget +RUN dnf update -yq \ + && dnf install -yq epel-release \ + && dnf install -yq \ + rpmdevtools libcurl-devel fontconfig-devel \ + freetype-devel openssl-devel glibc-devel \ + libicu-devel git wget dnf-plugins-core \ + && dnf clean all \ + && 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 \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet # Create symlinks and directories RUN ln -sf ${SOURCE_DIR}/deployment/build.centos.amd64 /build.sh \ - && mkdir -p ${SOURCE_DIR}/SPECS \ - && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ - && mkdir -p ${SOURCE_DIR}/SOURCES \ - && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES + && mkdir -p ${SOURCE_DIR}/SPECS \ + && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ + && mkdir -p ${SOURCE_DIR}/SOURCES \ + && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES VOLUME ${SOURCE_DIR}/ diff --git a/deployment/Dockerfile.debian.amd64 b/deployment/Dockerfile.debian.amd64 index d344c59646..da0c9dabd3 100644 --- a/deployment/Dockerfile.debian.amd64 +++ b/deployment/Dockerfile.debian.amd64 @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -10,11 +14,14 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ +RUN apt-get update -yq \ + && apt-get install --no-install-recommends -yq \ debhelper gnupg devscripts build-essential mmv \ - libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev \ - libssl1.1 liblttng-ust0 + libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev \ + libssl-dev libssl3 liblttng-ust1 \ + && apt-get clean autoclean -yq \ + && apt-get autoremove -yq \ + && rm -rf /var/lib/apt/lists/* # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.amd64 /build.sh diff --git a/deployment/Dockerfile.debian.arm64 b/deployment/Dockerfile.debian.arm64 index 8a5411f059..6c4cb816f5 100644 --- a/deployment/Dockerfile.debian.arm64 +++ b/deployment/Dockerfile.debian.arm64 @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,23 +15,26 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg devscripts build-essential mmv # Prepare the cross-toolchain RUN dpkg --add-architecture arm64 \ - && apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends cross-gcc-dev \ - && TARGET_LIST="arm64" cross-gcc-gensource 9 \ - && cd cross-gcc-packages-amd64/cross-gcc-9-arm64 \ - && apt-get install -yqq --no-install-recommends \ - gcc-9-source libstdc++-9-dev-arm64-cross \ + && apt-get update -yqq \ + && apt-get install --no-install-recommends -yqq cross-gcc-dev \ + && TARGET_LIST="arm64" cross-gcc-gensource 12 \ + && cd cross-gcc-packages-amd64/cross-gcc-12-arm64 \ + && apt-get install --no-install-recommends -yqq \ + gcc-12-source libstdc++-12-dev-arm64-cross \ binutils-aarch64-linux-gnu bison flex libtool \ gdb sharutils netbase libmpc-dev libmpfr-dev libgmp-dev \ systemtap-sdt-dev autogen expect chrpath zlib1g-dev zip \ libc6-dev:arm64 linux-libc-dev:arm64 libgcc1:arm64 \ libcurl4-openssl-dev:arm64 libfontconfig1-dev:arm64 \ - libfreetype6-dev:arm64 libssl-dev:arm64 liblttng-ust0:arm64 libstdc++-9-dev:arm64 + libfreetype6-dev:arm64 libssl-dev:arm64 liblttng-ust1:arm64 libstdc++-12-dev:arm64 \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.arm64 /build.sh diff --git a/deployment/Dockerfile.debian.armhf b/deployment/Dockerfile.debian.armhf index e95ba16962..b1fa6cee52 100644 --- a/deployment/Dockerfile.debian.armhf +++ b/deployment/Dockerfile.debian.armhf @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,24 +15,27 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg devscripts build-essential mmv # Prepare the cross-toolchain RUN dpkg --add-architecture armhf \ - && apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends cross-gcc-dev \ - && TARGET_LIST="armhf" cross-gcc-gensource 9 \ - && cd cross-gcc-packages-amd64/cross-gcc-9-armhf \ - && apt-get install -yqq --no-install-recommends\ - gcc-9-source libstdc++-9-dev-armhf-cross \ + && apt-get update -yqq \ + && apt-get install --no-install-recommends -yqq cross-gcc-dev \ + && TARGET_LIST="armhf" cross-gcc-gensource 12 \ + && cd cross-gcc-packages-amd64/cross-gcc-12-armhf \ + && apt-get install --no-install-recommends -yqq \ + gcc-12-source libstdc++-12-dev-armhf-cross \ binutils-aarch64-linux-gnu bison flex libtool gdb \ sharutils netbase libmpc-dev libmpfr-dev libgmp-dev \ systemtap-sdt-dev autogen expect chrpath zlib1g-dev \ zip binutils-arm-linux-gnueabihf libc6-dev:armhf \ linux-libc-dev:armhf libgcc1:armhf libcurl4-openssl-dev:armhf \ libfontconfig1-dev:armhf libfreetype6-dev:armhf libssl-dev:armhf \ - liblttng-ust0:armhf libstdc++-9-dev:armhf + liblttng-ust1:armhf libstdc++-12-dev:armhf \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.armhf /build.sh diff --git a/deployment/Dockerfile.docker.amd64 b/deployment/Dockerfile.docker.amd64 index 1749ca563c..ca16a08fbc 100644 --- a/deployment/Dockerfile.docker.amd64 +++ b/deployment/Dockerfile.docker.amd64 @@ -1,13 +1,12 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin WORKDIR ${SOURCE_DIR} COPY . . - ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# because of changes in docker and systemd we need to not build in parallel at the moment -# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting -RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="${ARTIFACT_DIR}" --self-contained --runtime linux-x64 -p:DebugSymbols=false -p:DebugType=none +RUN dotnet publish Jellyfin.Server --configuration Release --output="${ARTIFACT_DIR}" --self-contained --runtime linux-x64 -p:DebugSymbols=false -p:DebugType=none diff --git a/deployment/Dockerfile.docker.arm64 b/deployment/Dockerfile.docker.arm64 index bbddb61e4d..6e0f7d18e9 100644 --- a/deployment/Dockerfile.docker.arm64 +++ b/deployment/Dockerfile.docker.arm64 @@ -1,13 +1,12 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin WORKDIR ${SOURCE_DIR} COPY . . - ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# because of changes in docker and systemd we need to not build in parallel at the moment -# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting -RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="${ARTIFACT_DIR}" --self-contained --runtime linux-arm64 -p:DebugSymbols=false -p:DebugType=none +RUN dotnet publish Jellyfin.Server --configuration Release --output="${ARTIFACT_DIR}" --self-contained --runtime linux-arm64 -p:DebugSymbols=false -p:DebugType=none diff --git a/deployment/Dockerfile.docker.armhf b/deployment/Dockerfile.docker.armhf index 3de1d68878..44fb705e6d 100644 --- a/deployment/Dockerfile.docker.armhf +++ b/deployment/Dockerfile.docker.armhf @@ -1,13 +1,12 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin WORKDIR ${SOURCE_DIR} COPY . . - ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# because of changes in docker and systemd we need to not build in parallel at the moment -# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting -RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="${ARTIFACT_DIR}" --self-contained --runtime linux-arm -p:DebugSymbols=false -p:DebugType=none +RUN dotnet publish Jellyfin.Server --configuration Release --output="${ARTIFACT_DIR}" --self-contained --runtime linux-arm -p:DebugSymbols=false -p:DebugType=none diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64 index 66ead37d7d..75a6d1e649 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -1,7 +1,9 @@ FROM fedora:39 + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -9,21 +11,26 @@ ENV IS_DOCKER=YES # Prepare Fedora environment RUN dnf update -yq \ - && dnf install -yq @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel systemd wget make + && dnf install -yq \ + @buildsys-build rpmdevtools git \ + dnf-plugins-core libcurl-devel fontconfig-devel \ + freetype-devel openssl-devel glibc-devel \ + libicu-devel systemd wget make \ + && dnf clean all \ + && 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 \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet - + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet # Create symlinks and directories RUN ln -sf ${SOURCE_DIR}/deployment/build.fedora.amd64 /build.sh \ - && mkdir -p ${SOURCE_DIR}/SPECS \ - && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ - && mkdir -p ${SOURCE_DIR}/SOURCES \ - && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES + && mkdir -p ${SOURCE_DIR}/SPECS \ + && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ + && mkdir -p ${SOURCE_DIR}/SOURCES \ + && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES VOLUME ${SOURCE_DIR}/ diff --git a/deployment/Dockerfile.linux.amd64 b/deployment/Dockerfile.linux.amd64 index 386f7cefe0..6b8de3773f 100644 --- a/deployment/Dockerfile.linux.amd64 +++ b/deployment/Dockerfile.linux.amd64 @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,10 +15,13 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg devscripts unzip \ mmv libcurl4-openssl-dev libfontconfig1-dev \ - libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + libfreetype6-dev libssl-dev libssl3 liblttng-ust1 \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.amd64 /build.sh diff --git a/deployment/Dockerfile.linux.amd64-musl b/deployment/Dockerfile.linux.amd64-musl index 56c8773332..49d98da2ac 100644 --- a/deployment/Dockerfile.linux.amd64-musl +++ b/deployment/Dockerfile.linux.amd64-musl @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,10 +15,13 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg devscripts unzip \ mmv libcurl4-openssl-dev libfontconfig1-dev \ - libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + libfreetype6-dev libssl-dev libssl3 liblttng-ust1 \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.amd64-musl /build.sh diff --git a/deployment/Dockerfile.linux.arm64 b/deployment/Dockerfile.linux.arm64 index c9692c440a..aba33c8b23 100644 --- a/deployment/Dockerfile.linux.arm64 +++ b/deployment/Dockerfile.linux.arm64 @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,10 +15,13 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg devscripts unzip \ mmv libcurl4-openssl-dev libfontconfig1-dev \ - libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + libfreetype6-dev libssl-dev libssl3 liblttng-ust1 \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.arm64 /build.sh diff --git a/deployment/Dockerfile.linux.armhf b/deployment/Dockerfile.linux.armhf index 2304615560..247f756150 100644 --- a/deployment/Dockerfile.linux.armhf +++ b/deployment/Dockerfile.linux.armhf @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,10 +15,13 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg devscripts unzip \ mmv libcurl4-openssl-dev libfontconfig1-dev \ - libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + libfreetype6-dev libssl-dev libssl3 liblttng-ust1 \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.armhf /build.sh diff --git a/deployment/Dockerfile.linux.musl-linux-arm64 b/deployment/Dockerfile.linux.musl-linux-arm64 index 240d091869..a6e1ba217e 100644 --- a/deployment/Dockerfile.linux.musl-linux-arm64 +++ b/deployment/Dockerfile.linux.musl-linux-arm64 @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,10 +15,13 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ - apt-transport-https debhelper gnupg devscripts unzip \ + && apt-get install --no-install-recommends -yqq \ + debhelper gnupg devscripts unzip \ mmv libcurl4-openssl-dev libfontconfig1-dev \ - libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + libfreetype6-dev libssl-dev libssl3 liblttng-ust1 \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.musl-linux-arm64 /build.sh diff --git a/deployment/Dockerfile.macos.amd64 b/deployment/Dockerfile.macos.amd64 index 1b054dfc47..45980c363e 100644 --- a/deployment/Dockerfile.macos.amd64 +++ b/deployment/Dockerfile.macos.amd64 @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,10 +15,13 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg devscripts \ mmv libcurl4-openssl-dev libfontconfig1-dev \ - libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + libfreetype6-dev libssl-dev libssl3 liblttng-ust1 \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.macos.amd64 /build.sh diff --git a/deployment/Dockerfile.macos.arm64 b/deployment/Dockerfile.macos.arm64 index 07e18da55a..ee3a813dde 100644 --- a/deployment/Dockerfile.macos.arm64 +++ b/deployment/Dockerfile.macos.arm64 @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,10 +15,13 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg devscripts \ mmv libcurl4-openssl-dev libfontconfig1-dev \ - libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + libfreetype6-dev libssl-dev libssl3 liblttng-ust1 \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.macos.arm64 /build.sh diff --git a/deployment/Dockerfile.portable b/deployment/Dockerfile.portable index 36135f7a6e..0ab1b19147 100644 --- a/deployment/Dockerfile.portable +++ b/deployment/Dockerfile.portable @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -10,10 +14,13 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg devscripts \ mmv libcurl4-openssl-dev libfontconfig1-dev \ - libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + libfreetype6-dev libssl-dev libssl3 liblttng-ust1 \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.portable /build.sh diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index 84fa2028e5..2326d3e852 100644 --- a/deployment/Dockerfile.ubuntu.amd64 +++ b/deployment/Dockerfile.ubuntu.amd64 @@ -1,7 +1,11 @@ -FROM ubuntu:bionic +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-jammy + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,16 +15,13 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg wget ca-certificates devscripts \ mmv build-essential libcurl4-openssl-dev libfontconfig1-dev \ - libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -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 \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet + libfreetype6-dev libssl-dev libssl3 liblttng-ust1 \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.ubuntu.amd64 /build.sh diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index ca3aa35085..461a287a18 100644 --- a/deployment/Dockerfile.ubuntu.arm64 +++ b/deployment/Dockerfile.ubuntu.arm64 @@ -1,7 +1,11 @@ -FROM ubuntu:bionic +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-jammy + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,39 +15,36 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg wget ca-certificates devscripts \ mmv build-essential lsb-release -# Install dotnet repository -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 \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet - # Prepare the cross-toolchain RUN rm /etc/apt/sources.list \ - && export CODENAME="$( lsb_release -c -s )" \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && dpkg --add-architecture arm64 \ - && apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends cross-gcc-dev \ - && TARGET_LIST="arm64" cross-gcc-gensource 6 \ - && cd cross-gcc-packages-amd64/cross-gcc-6-arm64 \ - && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ - && apt-get install -yqq --no-install-recommends \ - gcc-6-source libstdc++6-arm64-cross binutils-aarch64-linux-gnu \ - bison flex libtool gdb sharutils netbase libcloog-isl-dev libmpc-dev \ + && export CODENAME="$( lsb_release -c -s )" \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && dpkg --add-architecture arm64 \ + && apt-get update -yqq \ + && apt-get install --no-install-recommends -yqq cross-gcc-dev \ + && TARGET_LIST="arm64" cross-gcc-gensource 12 \ + && cd cross-gcc-packages-amd64/cross-gcc-12-arm64 \ + && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ + && apt-get install --no-install-recommends -yqq \ + gcc-12-source libstdc++6-arm64-cross binutils-aarch64-linux-gnu \ + bison flex libtool gdb sharutils netbase libmpc-dev \ libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev \ zip libc6-dev:arm64 linux-libc-dev:arm64 libgcc1:arm64 libcurl4-openssl-dev:arm64 \ - libfontconfig1-dev:arm64 libfreetype6-dev:arm64 liblttng-ust0:arm64 libstdc++6:arm64 libssl-dev:arm64 + libfontconfig1-dev:arm64 libfreetype6-dev:arm64 liblttng-ust1:arm64 libstdc++6:arm64 libssl-dev:arm64 \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.ubuntu.arm64 /build.sh diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index e52b7fba35..83fe32acf8 100644 --- a/deployment/Dockerfile.ubuntu.armhf +++ b/deployment/Dockerfile.ubuntu.armhf @@ -1,7 +1,11 @@ -FROM ubuntu:bionic +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-jammy + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,39 +15,36 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg wget ca-certificates devscripts \ mmv build-essential lsb-release -# Install dotnet repository -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 \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet - # Prepare the cross-toolchain RUN rm /etc/apt/sources.list \ - && export CODENAME="$( lsb_release -c -s )" \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && dpkg --add-architecture armhf \ - && apt-get update -yqq \ - && apt-get install -yqq cross-gcc-dev \ - && TARGET_LIST="armhf" cross-gcc-gensource 6 \ - && cd cross-gcc-packages-amd64/cross-gcc-6-armhf \ - && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ - && apt-get install -yqq --no-install-recommends \ - gcc-6-source libstdc++6-armhf-cross binutils-arm-linux-gnueabihf \ - bison flex libtool gdb sharutils netbase libcloog-isl-dev libmpc-dev \ + && export CODENAME="$( lsb_release -c -s )" \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && dpkg --add-architecture armhf \ + && apt-get update -yqq \ + && apt-get install --no-install-recommends -yqq cross-gcc-dev \ + && TARGET_LIST="armhf" cross-gcc-gensource 12 \ + && cd cross-gcc-packages-amd64/cross-gcc-12-armhf \ + && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ + && apt-get install --no-install-recommends -yqq \ + gcc-12-source libstdc++6-armhf-cross binutils-arm-linux-gnueabihf \ + bison flex libtool gdb sharutils netbase libmpc-dev \ libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev \ zip libc6-dev:armhf linux-libc-dev:armhf libgcc1:armhf libcurl4-openssl-dev:armhf \ - libfontconfig1-dev:armhf libfreetype6-dev:armhf liblttng-ust0:armhf libstdc++6:armhf libssl-dev:armhf + libfontconfig1-dev:armhf libfreetype6-dev:armhf liblttng-ust1:armhf libstdc++6:armhf libssl-dev:armhf \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.armhf /build.sh diff --git a/deployment/Dockerfile.windows.amd64 b/deployment/Dockerfile.windows.amd64 index 08587aa7eb..358fb620ac 100644 --- a/deployment/Dockerfile.windows.amd64 +++ b/deployment/Dockerfile.windows.amd64 @@ -1,7 +1,11 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim +ARG DOTNET_VERSION=8.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-bookworm-slim + # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist + # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -10,10 +14,13 @@ ENV IS_DOCKER=YES # Prepare Debian build environment RUN apt-get update -yqq \ - && apt-get install -yqq --no-install-recommends \ + && apt-get install --no-install-recommends -yqq \ debhelper gnupg devscripts unzip \ mmv libcurl4-openssl-dev libfontconfig1-dev \ - libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 zip + libfreetype6-dev libssl-dev libssl3 liblttng-ust1 zip \ + && apt-get clean autoclean -yqq \ + && apt-get autoremove -yqq \ + && rm -rf /var/lib/apt/lists/* # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.windows.amd64 /build.sh diff --git a/deployment/build.centos.amd64 b/deployment/build.centos.amd64 index a0ab93e4e0..af73e31533 100755 --- a/deployment/build.centos.amd64 +++ b/deployment/build.centos.amd64 @@ -1,6 +1,6 @@ #!/bin/bash -#= CentOS/RHEL 7+ amd64 .rpm +#= CentOS/RHEL 8+ amd64 .rpm set -o errexit set -o xtrace @@ -42,7 +42,7 @@ rpmbuild --rebuild -bb /root/rpmbuild/SRPMS/jellyfin-*.src.rpm mv /root/rpmbuild/RPMS/x86_64/jellyfin-*.rpm /root/rpmbuild/SRPMS/jellyfin-*.src.rpm "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi rm -f fedora/jellyfin*.tar.gz @@ -51,7 +51,7 @@ if [[ ${IS_DOCKER} == YES ]]; then pushd fedora cp -a /tmp/spec.orig jellyfin.spec - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" popd fi diff --git a/deployment/build.debian.amd64 b/deployment/build.debian.amd64 index 1a59d02e91..85776ad6a0 100755 --- a/deployment/build.debian.amd64 +++ b/deployment/build.debian.amd64 @@ -37,7 +37,7 @@ mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.debian.arm64 b/deployment/build.debian.arm64 index e1e30fab4a..d37cc5a64a 100755 --- a/deployment/build.debian.arm64 +++ b/deployment/build.debian.arm64 @@ -38,7 +38,7 @@ mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.debian.armhf b/deployment/build.debian.armhf index e3e8ae004e..f3505b1478 100755 --- a/deployment/build.debian.armhf +++ b/deployment/build.debian.armhf @@ -38,7 +38,7 @@ mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.fedora.amd64 b/deployment/build.fedora.amd64 index da345ec08a..21859cbf9f 100755 --- a/deployment/build.fedora.amd64 +++ b/deployment/build.fedora.amd64 @@ -42,7 +42,7 @@ rpmbuild -rb /root/rpmbuild/SRPMS/jellyfin-*.src.rpm mv /root/rpmbuild/RPMS/x86_64/jellyfin-*.rpm /root/rpmbuild/SRPMS/jellyfin-*.src.rpm "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi rm -f fedora/jellyfin*.tar.gz @@ -51,7 +51,7 @@ if [[ ${IS_DOCKER} == YES ]]; then pushd fedora cp -a /tmp/spec.orig jellyfin.spec - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" popd fi diff --git a/deployment/build.linux.amd64 b/deployment/build.linux.amd64 index c6baa61f6e..2998d2f9e3 100755 --- a/deployment/build.linux.amd64 +++ b/deployment/build.linux.amd64 @@ -16,16 +16,16 @@ else fi # Build archives -dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-x64 --output dist/jellyfin-server_${version}/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true -tar -czf jellyfin-server_${version}_linux-amd64.tar.gz -C dist jellyfin-server_${version} -rm -rf dist/jellyfin-server_${version} +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-x64 --output dist/jellyfin-server_"${version}"/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true +tar -czf jellyfin-server_"${version}"_linux-amd64.tar.gz -C dist jellyfin-server_"${version}" +rm -rf dist/jellyfin-server_"${version}" # Move the artifacts out mkdir -p "${ARTIFACT_DIR}/" mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.linux.amd64-musl b/deployment/build.linux.amd64-musl index 6523f8319a..0fa1764650 100755 --- a/deployment/build.linux.amd64-musl +++ b/deployment/build.linux.amd64-musl @@ -16,16 +16,16 @@ else fi # Build archives -dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-musl-x64 --output dist/jellyfin-server_${version}/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true -tar -czf jellyfin-server_${version}_linux-amd64-musl.tar.gz -C dist jellyfin-server_${version} -rm -rf dist/jellyfin-server_${version} +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-musl-x64 --output dist/jellyfin-server_"${version}"/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true +tar -czf jellyfin-server_"${version}"_linux-amd64-musl.tar.gz -C dist jellyfin-server_"${version}" +rm -rf dist/jellyfin-server_"${version}" # Move the artifacts out mkdir -p "${ARTIFACT_DIR}/" mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.linux.arm64 b/deployment/build.linux.arm64 index 6d6a8f803a..dc44ca330d 100755 --- a/deployment/build.linux.arm64 +++ b/deployment/build.linux.arm64 @@ -16,16 +16,16 @@ else fi # Build archives -dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-arm64 --output dist/jellyfin-server_${version}/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true -tar -czf jellyfin-server_${version}_linux-arm64.tar.gz -C dist jellyfin-server_${version} -rm -rf dist/jellyfin-server_${version} +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-arm64 --output dist/jellyfin-server_"${version}"/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true +tar -czf jellyfin-server_"${version}"_linux-arm64.tar.gz -C dist jellyfin-server_"${version}" +rm -rf dist/jellyfin-server_"${version}" # Move the artifacts out mkdir -p "${ARTIFACT_DIR}/" mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.linux.armhf b/deployment/build.linux.armhf index 5167dfcb8d..f9de9ff0a3 100755 --- a/deployment/build.linux.armhf +++ b/deployment/build.linux.armhf @@ -16,16 +16,16 @@ else fi # Build archives -dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-arm --output dist/jellyfin-server_${version}/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true -tar -czf jellyfin-server_${version}_linux-armhf.tar.gz -C dist jellyfin-server_${version} -rm -rf dist/jellyfin-server_${version} +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-arm --output dist/jellyfin-server_"${version}"/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true +tar -czf jellyfin-server_"${version}"_linux-armhf.tar.gz -C dist jellyfin-server_"${version}" +rm -rf dist/jellyfin-server_"${version}" # Move the artifacts out mkdir -p "${ARTIFACT_DIR}/" mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.linux.musl-linux-arm64 b/deployment/build.linux.musl-linux-arm64 index 57980314d4..ae9ab010f5 100755 --- a/deployment/build.linux.musl-linux-arm64 +++ b/deployment/build.linux.musl-linux-arm64 @@ -16,16 +16,16 @@ else fi # Build archives -dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-musl-arm64 --output dist/jellyfin-server_${version}/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true -tar -czf jellyfin-server_${version}_linux-arm64-musl.tar.gz -C dist jellyfin-server_${version} -rm -rf dist/jellyfin-server_${version} +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-musl-arm64 --output dist/jellyfin-server_"${version}"/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true +tar -czf jellyfin-server_"${version}"_linux-arm64-musl.tar.gz -C dist jellyfin-server_"${version}" +rm -rf dist/jellyfin-server_"${version}" # Move the artifacts out mkdir -p "${ARTIFACT_DIR}/" mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.macos.amd64 b/deployment/build.macos.amd64 index c7711e82c7..81e0f43f62 100755 --- a/deployment/build.macos.amd64 +++ b/deployment/build.macos.amd64 @@ -16,16 +16,16 @@ else fi # Build archives -dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime osx-x64 --output dist/jellyfin-server_${version}/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true -tar -czf jellyfin-server_${version}_macos-amd64.tar.gz -C dist jellyfin-server_${version} -rm -rf dist/jellyfin-server_${version} +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime osx-x64 --output dist/jellyfin-server_"${version}"/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true +tar -czf jellyfin-server_"${version}"_macos-amd64.tar.gz -C dist jellyfin-server_"${version}" +rm -rf dist/jellyfin-server_"${version}" # Move the artifacts out mkdir -p "${ARTIFACT_DIR}/" mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.macos.arm64 b/deployment/build.macos.arm64 index b07eaad4e0..0a6f37edea 100755 --- a/deployment/build.macos.arm64 +++ b/deployment/build.macos.arm64 @@ -16,16 +16,16 @@ else fi # Build archives -dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime osx-arm64 --output dist/jellyfin-server_${version}/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true -tar -czf jellyfin-server_${version}_macos-arm64.tar.gz -C dist jellyfin-server_${version} -rm -rf dist/jellyfin-server_${version} +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime osx-arm64 --output dist/jellyfin-server_"${version}"/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true +tar -czf jellyfin-server_"${version}"_macos-arm64.tar.gz -C dist jellyfin-server_"${version}" +rm -rf dist/jellyfin-server_"${version}" # Move the artifacts out mkdir -p "${ARTIFACT_DIR}/" mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.portable b/deployment/build.portable index ec151d2956..fad14fccfc 100755 --- a/deployment/build.portable +++ b/deployment/build.portable @@ -16,16 +16,16 @@ else fi # Build archives -dotnet publish Jellyfin.Server --configuration Release --output dist/jellyfin-server_${version}/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=false -tar -czf jellyfin-server_${version}_portable.tar.gz -C dist jellyfin-server_${version} -rm -rf dist/jellyfin-server_${version} +dotnet publish Jellyfin.Server --configuration Release --output dist/jellyfin-server_"${version}"/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=false +tar -czf jellyfin-server_"${version}"_portable.tar.gz -C dist jellyfin-server_"${version}" +rm -rf dist/jellyfin-server_"${version}" # Move the artifacts out mkdir -p "${ARTIFACT_DIR}/" mv jellyfin[-_]*.tar.gz "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.ubuntu.amd64 b/deployment/build.ubuntu.amd64 index 17968a6e93..3658676035 100755 --- a/deployment/build.ubuntu.amd64 +++ b/deployment/build.ubuntu.amd64 @@ -37,7 +37,7 @@ mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.ubuntu.arm64 b/deployment/build.ubuntu.arm64 index ee7da9bb98..8cf24b955f 100755 --- a/deployment/build.ubuntu.arm64 +++ b/deployment/build.ubuntu.arm64 @@ -38,7 +38,7 @@ mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.ubuntu.armhf b/deployment/build.ubuntu.armhf index 85c993282e..896486dcd4 100755 --- a/deployment/build.ubuntu.armhf +++ b/deployment/build.ubuntu.armhf @@ -38,7 +38,7 @@ mv ../jellyfin*.{deb,dsc,tar.gz,buildinfo,changes} "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then cp -a /tmp/control.orig debian/control - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/deployment/build.windows.amd64 b/deployment/build.windows.amd64 index 20f976365d..cd07f4e0b2 100755 --- a/deployment/build.windows.amd64 +++ b/deployment/build.windows.amd64 @@ -23,30 +23,30 @@ fi output_dir="dist/jellyfin-server_${version}" # Build binary -dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime win-x64 --output ${output_dir}/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime win-x64 --output "${output_dir}"/ -p:DebugSymbols=false -p:DebugType=none -p:UseAppHost=true # Prepare addins addin_build_dir="$( mktemp -d )" -wget ${NSSM_URL} -O ${addin_build_dir}/nssm.zip -wget ${FFMPEG_URL} -O ${addin_build_dir}/jellyfin-ffmpeg.zip -unzip ${addin_build_dir}/nssm.zip -d ${addin_build_dir} -cp ${addin_build_dir}/${NSSM_VERSION}/win64/nssm.exe ${output_dir}/nssm.exe -unzip ${addin_build_dir}/jellyfin-ffmpeg.zip -d ${addin_build_dir}/jellyfin-ffmpeg -cp ${addin_build_dir}/jellyfin-ffmpeg/* ${output_dir} -rm -rf ${addin_build_dir} +wget ${NSSM_URL} -O "${addin_build_dir}"/nssm.zip +wget ${FFMPEG_URL} -O "${addin_build_dir}"/jellyfin-ffmpeg.zip +unzip "${addin_build_dir}"/nssm.zip -d "${addin_build_dir}" +cp "${addin_build_dir}"/${NSSM_VERSION}/win64/nssm.exe "${output_dir}"/nssm.exe +unzip "${addin_build_dir}"/jellyfin-ffmpeg.zip -d "${addin_build_dir}"/jellyfin-ffmpeg +cp "${addin_build_dir}"/jellyfin-ffmpeg/* "${output_dir}" +rm -rf "${addin_build_dir}" # Create zip package pushd dist -zip -qr jellyfin-server_${version}.portable.zip jellyfin-server_${version} +zip -qr jellyfin-server_"${version}".portable.zip jellyfin-server_"${version}" popd -rm -rf ${output_dir} +rm -rf "${output_dir}" # Move the artifacts out mkdir -p "${ARTIFACT_DIR}/" mv dist/jellyfin[-_]*.zip "${ARTIFACT_DIR}/" if [[ ${IS_DOCKER} == YES ]]; then - chown -Rc $(stat -c %u:%g "${ARTIFACT_DIR}") "${ARTIFACT_DIR}" + chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi popd diff --git a/fedora/README.md b/fedora/README.md index d449b51c16..6ea87740f2 100644 --- a/fedora/README.md +++ b/fedora/README.md @@ -14,8 +14,10 @@ The RPM package for Fedora/CentOS requires some additional repositories as ffmpe # ffmpeg from RPMfusion free # Fedora $ sudo dnf install https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm -# CentOS 7 -$ sudo yum localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-7.noarch.rpm +# CentOS 8 +$ sudo dnf localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-8.noarch.rpm +# CentOS 9 +$ sudo dnf localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-9.noarch.rpm ``` ## Building with dotnet @@ -26,8 +28,10 @@ Jellyfin is build with `--self-contained` so no dotnet required for runtime. # dotnet required for building the RPM # Fedora $ sudo dnf copr enable @dotnet-sig/dotnet -# CentOS -$ sudo rpm -Uvh https://packages.microsoft.com/config/rhel/7/packages-microsoft-prod.rpm +# CentOS 8 +$ sudo rpm -Uvh https://packages.microsoft.com/config/rhel/8/packages-microsoft-prod.rpm +# CentOS 9 +$ sudo rpm -Uvh https://packages.microsoft.com/config/rhel/9/packages-microsoft-prod.rpm ``` ## TODO diff --git a/fedora/jellyfin.spec b/fedora/jellyfin.spec index fb9fb2f7da..5327495ad3 100644 --- a/fedora/jellyfin.spec +++ b/fedora/jellyfin.spec @@ -1,10 +1,4 @@ %global debug_package %{nil} -# Set the dotnet runtime -%if 0%{?fedora} -%global dotnet_runtime fedora.%{fedora}-x64 -%else -%global dotnet_runtime centos-x64 -%endif Name: jellyfin Version: 10.9.0 @@ -29,12 +23,6 @@ BuildRequires: libcurl-devel, fontconfig-devel, freetype-devel, openssl-devel, BuildRequires: dotnet-runtime-8.0, dotnet-sdk-8.0 Requires: %{name}-server = %{version}-%{release}, %{name}-web = %{version}-%{release} -# Temporary (hopefully?) fix for https://github.com/jellyfin/jellyfin/issues/7471 -%if 0%{?fedora} >= 36 -%global __requires_exclude ^liblttng-ust\\.so\\.0.*$ -%endif - - %description Jellyfin is a free software media system that puts you in control of managing and streaming your media. @@ -66,14 +54,14 @@ the Jellyfin server to bind to ports 80 and/or 443 for example. export DOTNET_CLI_TELEMETRY_OPTOUT=1 export PATH=$PATH:/usr/local/bin # cannot use --output due to https://github.com/dotnet/sdk/issues/22220 -dotnet publish --configuration Release --self-contained --runtime %{dotnet_runtime} \ +dotnet publish --configuration Release --self-contained --runtime linux-x64 \ -p:DebugSymbols=false -p:DebugType=none Jellyfin.Server %install # Jellyfin files %{__mkdir} -p %{buildroot}%{_libdir}/jellyfin %{buildroot}%{_bindir} -%{__cp} -r Jellyfin.Server/bin/Release/net8.0/%{dotnet_runtime}/publish/* %{buildroot}%{_libdir}/jellyfin +%{__cp} -r Jellyfin.Server/bin/Release/net8.0/linux-x64/publish/* %{buildroot}%{_libdir}/jellyfin %{__install} -D %{SOURCE10} %{buildroot}%{_bindir}/jellyfin sed -i -e 's|/usr/lib64|%{_libdir}|g' %{buildroot}%{_bindir}/jellyfin -- cgit v1.2.3 From 2fa50cceef6dede0b80fc9f528f057553a7928fc Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Wed, 17 Jan 2024 18:21:20 -0700 Subject: Use NuGetAuthenticate@1 --- .ci/azure-pipelines-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml index 39f98e063e..b0684c0d4c 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -259,7 +259,7 @@ jobs: publishFeedCredentials: 'NugetOrg' allowPackageConflicts: true # This ignores an error if the version already exists - - task: NuGetAuthenticate@0 + - task: NuGetAuthenticate@1 displayName: 'Authenticate to unstable Nuget feed' condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master') -- cgit v1.2.3 From 879ac12d433f94f40b2630d2de39cc0896990374 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Jan 2024 21:00:58 +0000 Subject: chore(deps): update actions/upload-artifact action to v4.2.0 --- .github/workflows/ci-openapi.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 75ec82c5f1..63268001e0 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -25,7 +25,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@1eb3cb2b3e0f29609092a73eb033bb759a334595 # v4.1.0 + uses: actions/upload-artifact@694cdabd8bdb0f10b2cea11669e1bf5453eed0a6 # v4.2.0 with: name: openapi-head retention-days: 14 @@ -59,7 +59,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@1eb3cb2b3e0f29609092a73eb033bb759a334595 # v4.1.0 + uses: actions/upload-artifact@694cdabd8bdb0f10b2cea11669e1bf5453eed0a6 # v4.2.0 with: name: openapi-base retention-days: 14 -- cgit v1.2.3 From 67b6c4f136fa5bcacb118475eacb140fe40b53d6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 04:33:45 +0000 Subject: chore(deps): update dependency serilog.aspnetcore to v8.0.1 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index bc87f9fc96..687bc19f94 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -57,7 +57,7 @@ - + -- cgit v1.2.3 From 04a063aa8dc131f6e5f7f6bca2d339d3f6bc3c4a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Jan 2024 10:50:28 +0000 Subject: chore(deps): update dependency efcoresecondlevelcacheinterceptor to v4.1.2 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 687bc19f94..d6a9921586 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,7 +15,7 @@ - + -- cgit v1.2.3 From 9f945260094f177608e41f80a48e019e01ca1d91 Mon Sep 17 00:00:00 2001 From: jonathan1jansson Date: Thu, 18 Jan 2024 13:43:13 +0000 Subject: Translated using Weblate (Swedish) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sv/ --- Emby.Server.Implementations/Localization/Core/sv.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 97062deece..1fc3cdbaaa 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -43,7 +43,7 @@ "NameInstallFailed": "{0} installationen misslyckades", "NameSeasonNumber": "Säsong {0}", "NameSeasonUnknown": "Okänd säsong", - "NewVersionIsAvailable": "En ny version av Jellyfin Server är tillgänglig att hämta.", + "NewVersionIsAvailable": "En ny version av Jellyfin Server är tillgänglig för nedladdning.", "NotificationOptionApplicationUpdateAvailable": "Ny programversion tillgänglig", "NotificationOptionApplicationUpdateInstalled": "Programuppdatering installerad", "NotificationOptionAudioPlayback": "Ljuduppspelning har påbörjats", @@ -74,7 +74,7 @@ "Songs": "Låtar", "StartupEmbyServerIsLoading": "Jellyfin Server arbetar. Pröva igen snart.", "SubtitleDownloadFailureForItem": "Nerladdning av undertexter för {0} misslyckades", - "SubtitleDownloadFailureFromForItem": "Undertexter kunde inte laddas ner från {0} för {1}", + "SubtitleDownloadFailureFromForItem": "Undertexter kunde inte laddas ner från {0} till {1}", "Sync": "Synk", "System": "System", "TvShows": "TV-serier", -- cgit v1.2.3 From 94e3fed3c9816012ebfaab6c8dadbef5d003eca9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 20 Jan 2024 16:23:51 +0000 Subject: chore(deps): update dependency metabrainz.musicbrainz to v6.1.0 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 687bc19f94..24bed643b1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,7 @@ - + -- cgit v1.2.3 From 1ff23692285597e5f751845674edff87bbe71391 Mon Sep 17 00:00:00 2001 From: hulkhaugen Date: Fri, 19 Jan 2024 19:12:59 +0000 Subject: Translated using Weblate (Norwegian Bokmål) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nb_NO/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Emby.Server.Implementations/Localization/Core/nb.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 0362c24179..e64252d897 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -32,10 +32,10 @@ "LabelIpAddressValue": "IP-adresse: {0}", "LabelRunningTimeValue": "Spilletid {0}", "Latest": "Siste", - "MessageApplicationUpdated": "Jellyfin-tjeneren har blitt oppdatert", - "MessageApplicationUpdatedTo": "Jellyfin-tjeneren ble oppdatert til {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Tjenerkonfigurasjonsseksjon {0} har blitt oppdatert", - "MessageServerConfigurationUpdated": "Tjenerkonfigurasjon er oppdatert", + "MessageApplicationUpdated": "Jellyfin-serveren har blitt oppdatert", + "MessageApplicationUpdatedTo": "Jellyfin-serveren ble oppdatert til {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Serverkonfigurasjonsseksjon {0} har blitt oppdatert", + "MessageServerConfigurationUpdated": "Serverkonfigurasjon har blitt oppdatert", "MixedContent": "Blandet innhold", "Movies": "Filmer", "Music": "Musikk", @@ -43,7 +43,7 @@ "NameInstallFailed": "Installasjonen av {0} mislyktes", "NameSeasonNumber": "Sesong {0}", "NameSeasonUnknown": "Ukjent sesong", - "NewVersionIsAvailable": "En ny versjon av Jellyfin-tjeneren er tilgjengelig for nedlasting.", + "NewVersionIsAvailable": "En ny versjon av Jellyfin Server er tilgjengelig for nedlasting.", "NotificationOptionApplicationUpdateAvailable": "En programvareoppdatering er tilgjengelig", "NotificationOptionApplicationUpdateInstalled": "Applikasjonsoppdatering installert", "NotificationOptionAudioPlayback": "Lydavspilling startet", @@ -55,7 +55,7 @@ "NotificationOptionPluginInstalled": "Programvareutvidelse installert", "NotificationOptionPluginUninstalled": "Programvareutvidelse avinstallert", "NotificationOptionPluginUpdateInstalled": "Programvareutvidelsesoppdatering installert", - "NotificationOptionServerRestartRequired": "Tjeneromstart er nødvendig", + "NotificationOptionServerRestartRequired": "Serveromstart er nødvendig", "NotificationOptionTaskFailed": "Feil under utføring av planlagt oppgave", "NotificationOptionUserLockedOut": "Bruker er utestengt", "NotificationOptionVideoPlayback": "Videoavspilling startet", @@ -70,9 +70,9 @@ "ScheduledTaskFailedWithName": "{0} mislykkes", "ScheduledTaskStartedWithName": "{0} startet", "ServerNameNeedsToBeRestarted": "{0} må startes på nytt", - "Shows": "Program", + "Shows": "Serier", "Songs": "Sanger", - "StartupEmbyServerIsLoading": "Jellyfin-tjener laster. Prøv igjen snart.", + "StartupEmbyServerIsLoading": "Jellyfin Server laster. Prøv igjen snart.", "SubtitleDownloadFailureForItem": "En feil oppstå under nedlasting av undertekster for {0}", "SubtitleDownloadFailureFromForItem": "Kunne ikke laste ned undertekster fra {0} for {1}", "Sync": "Synkroniser", -- cgit v1.2.3 From 5e375888fc19aebd1348d23b28287524f0f0f23b Mon Sep 17 00:00:00 2001 From: Zoe Date: Fri, 19 Jan 2024 21:23:38 +0000 Subject: Translated using Weblate (Norwegian Bokmål) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nb_NO/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Emby.Server.Implementations/Localization/Core/nb.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index e64252d897..b6c15d871a 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -5,7 +5,7 @@ "Artists": "Artister", "AuthenticationSucceededWithUserName": "{0} har logget inn", "Books": "Bøker", - "CameraImageUploadedFrom": "Et nytt kamerabilde er lastet opp fra {0}", + "CameraImageUploadedFrom": "Et nytt kamerabilde har blitt lastet opp fra {0}", "Channels": "Kanaler", "ChapterNameValue": "Kapittel {0}", "Collections": "Samlinger", @@ -49,7 +49,7 @@ "NotificationOptionAudioPlayback": "Lydavspilling startet", "NotificationOptionAudioPlaybackStopped": "Lydavspilling stoppet", "NotificationOptionCameraImageUploaded": "Kamerabilde lastet opp", - "NotificationOptionInstallationFailed": "Installasjonen feilet", + "NotificationOptionInstallationFailed": "Installasjonsfeil", "NotificationOptionNewLibraryContent": "Nytt innhold lagt til", "NotificationOptionPluginError": "Programvareutvidelsesfeil", "NotificationOptionPluginInstalled": "Programvareutvidelse installert", -- cgit v1.2.3 From 30ab5d5e817fe4626cfe3e9e40e0b2443bae948c Mon Sep 17 00:00:00 2001 From: Gauvino <68083474+Gauvino@users.noreply.github.com> Date: Sun, 21 Jan 2024 18:55:01 +0100 Subject: Fix action building (#10899) * Fix action building * Added required package --- deployment/Dockerfile.centos.amd64 | 6 +++--- deployment/build.centos.amd64 | 12 ++++++------ deployment/build.debian.amd64 | 8 +------- deployment/build.debian.arm64 | 8 +------- deployment/build.debian.armhf | 8 +------- deployment/build.fedora.amd64 | 2 +- deployment/build.ubuntu.amd64 | 8 +------- deployment/build.ubuntu.arm64 | 8 +------- deployment/build.ubuntu.armhf | 8 +------- 9 files changed, 16 insertions(+), 52 deletions(-) diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index 3db184f494..af309b0831 100644 --- a/deployment/Dockerfile.centos.amd64 +++ b/deployment/Dockerfile.centos.amd64 @@ -11,11 +11,11 @@ ENV IS_DOCKER=YES # Prepare CentOS environment RUN dnf update -yq \ - && dnf install -yq epel-release \ && dnf install -yq \ - rpmdevtools libcurl-devel fontconfig-devel \ + @buildsys-build rpmdevtools git \ + dnf-plugins-core libcurl-devel fontconfig-devel \ freetype-devel openssl-devel glibc-devel \ - libicu-devel git wget dnf-plugins-core \ + libicu-devel systemd wget make \ && dnf clean all \ && rm -rf /var/cache/dnf diff --git a/deployment/build.centos.amd64 b/deployment/build.centos.amd64 index af73e31533..26be377f10 100755 --- a/deployment/build.centos.amd64 +++ b/deployment/build.centos.amd64 @@ -1,6 +1,6 @@ #!/bin/bash -#= CentOS/RHEL 8+ amd64 .rpm +#= CentOS/RHEL 9+ amd64 .rpm set -o errexit set -o xtrace @@ -10,7 +10,7 @@ pushd "${SOURCE_DIR}" if [[ ${IS_DOCKER} == YES ]]; then # Remove BuildRequires for dotnet, since it's installed manually - pushd fedora + pushd centos cp -a jellyfin.spec /tmp/spec.orig sed -i 's/BuildRequires: dotnet/# BuildRequires: dotnet/' jellyfin.spec @@ -20,7 +20,7 @@ fi # Modify changelog to unstable configuration if IS_UNSTABLE if [[ ${IS_UNSTABLE} == 'yes' ]]; then - pushd fedora + pushd centos PR_ID=$( git log --grep 'Merge pull request' --oneline --single-worktree --first-parent | head -1 | grep --color=none -Eo '#[0-9]+' | tr -d '#' ) @@ -35,7 +35,7 @@ EOF fi # Build RPM -make -f fedora/Makefile srpm outdir=/root/rpmbuild/SRPMS +make -f centos/Makefile srpm outdir=/root/rpmbuild/SRPMS rpmbuild --rebuild -bb /root/rpmbuild/SRPMS/jellyfin-*.src.rpm # Move the artifacts out @@ -45,10 +45,10 @@ if [[ ${IS_DOCKER} == YES ]]; then chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" fi -rm -f fedora/jellyfin*.tar.gz +rm -f centos/jellyfin*.tar.gz if [[ ${IS_DOCKER} == YES ]]; then - pushd fedora + pushd centos cp -a /tmp/spec.orig jellyfin.spec chown -Rc "$(stat -c %u:%g "${ARTIFACT_DIR}")" "${ARTIFACT_DIR}" diff --git a/deployment/build.debian.amd64 b/deployment/build.debian.amd64 index 85776ad6a0..350b22a854 100755 --- a/deployment/build.debian.amd64 +++ b/deployment/build.debian.amd64 @@ -1,6 +1,6 @@ #!/bin/bash -#= Debian 10+ amd64 .deb +#= Debian 12+ amd64 .deb set -o errexit set -o xtrace @@ -8,12 +8,6 @@ set -o xtrace # Move to source directory pushd "${SOURCE_DIR}" -if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-8.0, since it's installed manually - cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-8.0,/d' debian/control -fi - # Modify changelog to unstable configuration if IS_UNSTABLE if [[ ${IS_UNSTABLE} == 'yes' ]]; then pushd debian diff --git a/deployment/build.debian.arm64 b/deployment/build.debian.arm64 index d37cc5a64a..0dfca0ab49 100755 --- a/deployment/build.debian.arm64 +++ b/deployment/build.debian.arm64 @@ -1,6 +1,6 @@ #!/bin/bash -#= Debian 10+ arm64 .deb +#= Debian 12+ arm64 .deb set -o errexit set -o xtrace @@ -8,12 +8,6 @@ set -o xtrace # Move to source directory pushd "${SOURCE_DIR}" -if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-8.0, since it's installed manually - cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-8.0,/d' debian/control -fi - # Modify changelog to unstable configuration if IS_UNSTABLE if [[ ${IS_UNSTABLE} == 'yes' ]]; then pushd debian diff --git a/deployment/build.debian.armhf b/deployment/build.debian.armhf index f3505b1478..0ab9e2f9a4 100755 --- a/deployment/build.debian.armhf +++ b/deployment/build.debian.armhf @@ -1,6 +1,6 @@ #!/bin/bash -#= Debian 10+ arm64 .deb +#= Debian 12+ arm64 .deb set -o errexit set -o xtrace @@ -8,12 +8,6 @@ set -o xtrace # Move to source directory pushd "${SOURCE_DIR}" -if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-8.0, since it's installed manually - cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-8.0,/d' debian/control -fi - # Modify changelog to unstable configuration if IS_UNSTABLE if [[ ${IS_UNSTABLE} == 'yes' ]]; then pushd debian diff --git a/deployment/build.fedora.amd64 b/deployment/build.fedora.amd64 index 21859cbf9f..2b4ec2a9c5 100755 --- a/deployment/build.fedora.amd64 +++ b/deployment/build.fedora.amd64 @@ -1,6 +1,6 @@ #!/bin/bash -#= Fedora 29+ amd64 .rpm +#= Fedora 39+ amd64 .rpm set -o errexit set -o xtrace diff --git a/deployment/build.ubuntu.amd64 b/deployment/build.ubuntu.amd64 index 3658676035..6fd87a3aec 100755 --- a/deployment/build.ubuntu.amd64 +++ b/deployment/build.ubuntu.amd64 @@ -1,6 +1,6 @@ #!/bin/bash -#= Ubuntu 18.04+ amd64 .deb +#= Ubuntu 22.04+ amd64 .deb set -o errexit set -o xtrace @@ -8,12 +8,6 @@ set -o xtrace # Move to source directory pushd "${SOURCE_DIR}" -if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-8.0, since it's installed manually - cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-8.0,/d' debian/control -fi - # Modify changelog to unstable configuration if IS_UNSTABLE if [[ ${IS_UNSTABLE} == 'yes' ]]; then pushd debian diff --git a/deployment/build.ubuntu.arm64 b/deployment/build.ubuntu.arm64 index 8cf24b955f..f783941c73 100755 --- a/deployment/build.ubuntu.arm64 +++ b/deployment/build.ubuntu.arm64 @@ -1,6 +1,6 @@ #!/bin/bash -#= Ubuntu 18.04+ arm64 .deb +#= Ubuntu 22.04+ arm64 .deb set -o errexit set -o xtrace @@ -8,12 +8,6 @@ set -o xtrace # Move to source directory pushd "${SOURCE_DIR}" -if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-8.0, since it's installed manually - cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-8.0,/d' debian/control -fi - # Modify changelog to unstable configuration if IS_UNSTABLE if [[ ${IS_UNSTABLE} == 'yes' ]]; then pushd debian diff --git a/deployment/build.ubuntu.armhf b/deployment/build.ubuntu.armhf index 896486dcd4..cde6708c5b 100755 --- a/deployment/build.ubuntu.armhf +++ b/deployment/build.ubuntu.armhf @@ -1,6 +1,6 @@ #!/bin/bash -#= Ubuntu 18.04+ arm64 .deb +#= Ubuntu 22.04+ arm64 .deb set -o errexit set -o xtrace @@ -8,12 +8,6 @@ set -o xtrace # Move to source directory pushd "${SOURCE_DIR}" -if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-8.0, since it's installed manually - cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-8.0,/d' debian/control -fi - # Modify changelog to unstable configuration if IS_UNSTABLE if [[ ${IS_UNSTABLE} == 'yes' ]]; then pushd debian -- cgit v1.2.3 From 7027bc00fc06314929011735e425763779cb4076 Mon Sep 17 00:00:00 2001 From: Mustafa Date: Sun, 21 Jan 2024 15:49:15 -0500 Subject: Added translation using Weblate (Urdu) --- Emby.Server.Implementations/Localization/Core/ur.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 Emby.Server.Implementations/Localization/Core/ur.json diff --git a/Emby.Server.Implementations/Localization/Core/ur.json b/Emby.Server.Implementations/Localization/Core/ur.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ur.json @@ -0,0 +1 @@ +{} -- cgit v1.2.3 From 0cf477b4fed45f80e0deb25dd2f3569c4da7ea50 Mon Sep 17 00:00:00 2001 From: Mustafa Date: Sun, 21 Jan 2024 20:50:26 +0000 Subject: Translated using Weblate (Urdu) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ur/ --- Emby.Server.Implementations/Localization/Core/ur.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/ur.json b/Emby.Server.Implementations/Localization/Core/ur.json index 0967ef424b..3766830413 100644 --- a/Emby.Server.Implementations/Localization/Core/ur.json +++ b/Emby.Server.Implementations/Localization/Core/ur.json @@ -1 +1,3 @@ -{} +{ + "Books": "کتابیں" +} -- cgit v1.2.3 From e2b2399ea60c549fc8285bf8c8a9651a1e8ba635 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 23 Jan 2024 20:05:50 +0000 Subject: chore(deps): update actions/upload-artifact action to v4.3.0 --- .github/workflows/ci-openapi.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 63268001e0..e43160562f 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -25,7 +25,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@694cdabd8bdb0f10b2cea11669e1bf5453eed0a6 # v4.2.0 + uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 with: name: openapi-head retention-days: 14 @@ -59,7 +59,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@694cdabd8bdb0f10b2cea11669e1bf5453eed0a6 # v4.2.0 + uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 with: name: openapi-base retention-days: 14 -- cgit v1.2.3 From 989a80e05238d870f9d5b34d142a73e5845635cd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Jan 2024 10:21:05 +0000 Subject: chore(deps): update dependency efcoresecondlevelcacheinterceptor to v4.2.0 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index fe9280965c..dcf1834949 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,7 +15,7 @@ - + -- cgit v1.2.3