From ca7d1a13000ad948eebbfdeb40542312f3e37d3e Mon Sep 17 00:00:00 2001 From: nicknsy <20588554+nicknsy@users.noreply.github.com> Date: Wed, 22 Feb 2023 00:08:35 -0800 Subject: Trickplay generation, manager, storage --- Emby.Server.Implementations/ApplicationHost.cs | 3 + .../Data/SqliteItemRepository.cs | 123 +++++++++++++++++++++ Emby.Server.Implementations/Dto/DtoService.cs | 5 + 3 files changed, 131 insertions(+) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 7969577bc0..1e0bb0cd6f 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -78,6 +78,7 @@ using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Sorting; using MediaBrowser.Controller.Subtitles; using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.Trickplay; using MediaBrowser.Controller.TV; using MediaBrowser.LocalMetadata.Savers; using MediaBrowser.MediaEncoding.BdInfo; @@ -96,6 +97,7 @@ using MediaBrowser.Providers.Lyric; using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Plugins.Tmdb; using MediaBrowser.Providers.Subtitles; +using MediaBrowser.Providers.Trickplay; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -591,6 +593,7 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index ca8f605a02..8ec24522b0 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -48,6 +48,7 @@ namespace Emby.Server.Implementations.Data { private const string FromText = " from TypedBaseItems A"; private const string ChaptersTableName = "Chapters2"; + private const string TrickplayTableName = "Trickplay"; private const string SaveItemCommandText = @"replace into TypedBaseItems @@ -383,6 +384,8 @@ namespace Emby.Server.Implementations.Data "create table if not exists " + ChaptersTableName + " (ItemId GUID, ChapterIndex INT NOT NULL, StartPositionTicks BIGINT NOT NULL, Name TEXT, ImagePath TEXT, PRIMARY KEY (ItemId, ChapterIndex))", + "create table if not exists " + TrickplayTableName + " (ItemId GUID, Width INT NOT NULL, Height INT NOT NULL, TileWidth INT NOT NULL, TileHeight INT NOT NULL, TileCount INT NOT NULL, Interval INT NOT NULL, Bandwidth INT NOT NULL, PRIMARY KEY (ItemId, Width))", + CreateMediaStreamsTableCommand, CreateMediaAttachmentsTableCommand, @@ -2135,6 +2138,126 @@ namespace Emby.Server.Implementations.Data } } + /// + public Dictionary GetTilesResolutions(Guid itemId) + { + CheckDisposed(); + + var tilesResolutions = new Dictionary(); + using (var connection = GetConnection(true)) + { + using (var statement = PrepareStatement(connection, "select Width,Height,TileWidth,TileHeight,TileCount,Interval,Bandwidth from " + TrickplayTableName + " where ItemId = @ItemId order by Width asc")) + { + statement.TryBind("@ItemId", itemId); + + foreach (var row in statement.ExecuteQuery()) + { + TrickplayTilesInfo tilesInfo = GetTrickplayTilesInfo(row); + tilesResolutions[tilesInfo.Width] = tilesInfo; + } + } + } + + return tilesResolutions; + } + + /// + public void SaveTilesInfo(Guid itemId, TrickplayTilesInfo tilesInfo) + { + CheckDisposed(); + + ArgumentNullException.ThrowIfNull(tilesInfo); + + var idBlob = itemId.ToByteArray(); + using (var connection = GetConnection(false)) + { + connection.RunInTransaction( + db => + { + // Delete old tiles info + db.Execute("delete from " + TrickplayTableName + " where ItemId=@ItemId and Width=@Width", idBlob, tilesInfo.Width); + db.Execute( + "insert into " + TrickplayTableName + " values (@ItemId, @Width, @Height, @TileWidth, @TileHeight, @TileCount, @Interval, @Bandwidth)", + idBlob, + tilesInfo.Width, + tilesInfo.Height, + tilesInfo.TileWidth, + tilesInfo.TileHeight, + tilesInfo.TileCount, + tilesInfo.Interval, + tilesInfo.Bandwidth); + }, + TransactionMode); + } + } + + /// + public Dictionary> GetTrickplayManifest(BaseItem item) + { + CheckDisposed(); + + var trickplayManifest = new Dictionary>(); + foreach (var mediaSource in item.GetMediaSources(false)) + { + var mediaSourceId = Guid.Parse(mediaSource.Id); + var tilesResolutions = GetTilesResolutions(mediaSourceId); + + if (tilesResolutions.Count > 0) + { + trickplayManifest[mediaSourceId] = tilesResolutions; + } + } + + return trickplayManifest; + } + + /// + /// Gets the trickplay tiles info. + /// + /// The reader. + /// TrickplayTilesInfo. + private TrickplayTilesInfo GetTrickplayTilesInfo(IReadOnlyList reader) + { + var tilesInfo = new TrickplayTilesInfo(); + + if (reader.TryGetInt32(0, out var width)) + { + tilesInfo.Width = width; + } + + if (reader.TryGetInt32(1, out var height)) + { + tilesInfo.Height = height; + } + + if (reader.TryGetInt32(2, out var tileWidth)) + { + tilesInfo.TileWidth = tileWidth; + } + + if (reader.TryGetInt32(3, out var tileHeight)) + { + tilesInfo.TileHeight = tileHeight; + } + + if (reader.TryGetInt32(4, out var tileCount)) + { + tilesInfo.TileCount = tileCount; + } + + if (reader.TryGetInt32(5, out var interval)) + { + tilesInfo.Interval = interval; + } + + if (reader.TryGetInt32(6, out var bandwidth)) + { + tilesInfo.Bandwidth = bandwidth; + } + + return tilesInfo; + } + private static bool EnableJoinUserData(InternalItemsQuery query) { if (query.User is null) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 7a6ed2cb80..10352b6ff8 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1058,6 +1058,11 @@ namespace Emby.Server.Implementations.Dto dto.Chapters = _itemRepo.GetChapters(item); } + if (options.ContainsField(ItemFields.Trickplay)) + { + dto.Trickplay = _itemRepo.GetTrickplayManifest(item); + } + if (video.ExtraType.HasValue) { dto.ExtraType = video.ExtraType.Value.ToString(); -- cgit v1.2.3 From d84370a6f72111b544f1bbcaa8f3b0339148b5f8 Mon Sep 17 00:00:00 2001 From: nicknsy <20588554+nicknsy@users.noreply.github.com> Date: Sat, 25 Feb 2023 20:36:38 -0800 Subject: Make trickplay response ids have no dashes --- Emby.Server.Implementations/Dto/DtoService.cs | 4 +++- MediaBrowser.Model/Dto/BaseItemDto.cs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 10352b6ff8..1687fa442b 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1060,7 +1060,9 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.Trickplay)) { - dto.Trickplay = _itemRepo.GetTrickplayManifest(item); + // To stay consistent with other fields, this must go from a Guid to a non-dashed string. + // This does not seem to occur automatically to dictionaries like it does with other Guid fields. + dto.Trickplay = _itemRepo.GetTrickplayManifest(item).ToDictionary(x => x.Key.ToString("N", CultureInfo.InvariantCulture), y => y.Value); } if (video.ExtraType.HasValue) diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index ab424c6f5c..3db9cb08b5 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -572,7 +572,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the trickplay manifest. /// /// The trickplay manifest. - public Dictionary> Trickplay { get; set; } + public Dictionary> Trickplay { get; set; } /// /// Gets or sets the type of the location. -- cgit v1.2.3 From d7fd54197c98346844b53860b280f0a6e65f6180 Mon Sep 17 00:00:00 2001 From: Nick <20588554+nicknsy@users.noreply.github.com> Date: Tue, 28 Mar 2023 12:45:41 -0700 Subject: Task localization --- Emby.Server.Implementations/Localization/Core/en-US.json | 2 ++ MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs | 9 --------- 2 files changed, 2 insertions(+), 9 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 15088384cc..496ecabd37 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -112,6 +112,8 @@ "TaskCleanLogsDescription": "Deletes log files that are more than {0} days old.", "TaskRefreshPeople": "Refresh People", "TaskRefreshPeopleDescription": "Updates metadata for actors and directors in your media library.", + "TaskRefreshTrickplayImages": "Generate Trickplay Images", + "TaskRefreshTrickplayImagesDescription": "Creates trickplay previews for videos in enabled libraries.", "TaskUpdatePlugins": "Update Plugins", "TaskUpdatePluginsDescription": "Downloads and installs updates for plugins that are configured to update automatically.", "TaskCleanTranscode": "Clean Transcode Directory", diff --git a/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs index 50166ca39f..8d0d9d5a34 100644 --- a/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs +++ b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs @@ -71,15 +71,6 @@ namespace MediaBrowser.Providers.Trickplay /// public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) { - // TODO: libraryoptions dont run on libraries with trickplay disabled - /* will this still get all sub-items? should recursive be true? - * from chapterimagestask - * DtoOptions = new DtoOptions(false) - { - EnableImages = false - }, - SourceTypes = new SourceType[] { SourceType.Library }, - */ var items = _libraryManager.GetItemList(new InternalItemsQuery { MediaTypes = new[] { MediaType.Video }, -- cgit v1.2.3 From ab20ceaad65b2e72fe6e823aa6086e2c6ac36844 Mon Sep 17 00:00:00 2001 From: Nick <20588554+nicknsy@users.noreply.github.com> Date: Mon, 26 Jun 2023 17:40:10 -0700 Subject: Migrate to trickplay table to EF. Rename vars/methods/members to have consistent use of tile and thumbnail --- Emby.Server.Implementations/ApplicationHost.cs | 3 - .../Data/SqliteItemRepository.cs | 124 ---- Emby.Server.Implementations/Dto/DtoService.cs | 10 +- Jellyfin.Api/Controllers/TrickplayController.cs | 19 +- Jellyfin.Api/Helpers/DynamicHlsHelper.cs | 18 +- Jellyfin.Data/Entities/TrickplayInfo.cs | 75 +++ .../JellyfinDbContext.cs | 5 + .../20230626233818_AddTrickplayInfos.Designer.cs | 681 +++++++++++++++++++++ .../Migrations/20230626233818_AddTrickplayInfos.cs | 40 ++ .../Migrations/JellyfinDbModelSnapshot.cs | 35 +- .../TrickplayInfoConfiguration.cs | 18 + .../Trickplay/TrickplayManager.cs | 468 ++++++++++++++ Jellyfin.Server/CoreAppHost.cs | 3 + MediaBrowser.Controller/Drawing/IImageEncoder.cs | 13 +- .../Persistence/IItemRepository.cs | 21 - .../Trickplay/ITrickplayManager.cs | 30 +- MediaBrowser.Model/Dto/BaseItemDto.cs | 3 +- MediaBrowser.Model/Entities/TrickplayTilesInfo.cs | 49 -- .../Trickplay/TrickplayManager.cs | 425 ------------- src/Jellyfin.Drawing.Skia/SkiaEncoder.cs | 2 +- src/Jellyfin.Drawing/NullImageEncoder.cs | 2 +- 21 files changed, 1375 insertions(+), 669 deletions(-) create mode 100644 Jellyfin.Data/Entities/TrickplayInfo.cs create mode 100644 Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs create mode 100644 Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs create mode 100644 Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs create mode 100644 Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs delete mode 100644 MediaBrowser.Model/Entities/TrickplayTilesInfo.cs delete mode 100644 MediaBrowser.Providers/Trickplay/TrickplayManager.cs (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 1e0bb0cd6f..7969577bc0 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -78,7 +78,6 @@ using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Sorting; using MediaBrowser.Controller.Subtitles; using MediaBrowser.Controller.SyncPlay; -using MediaBrowser.Controller.Trickplay; using MediaBrowser.Controller.TV; using MediaBrowser.LocalMetadata.Savers; using MediaBrowser.MediaEncoding.BdInfo; @@ -97,7 +96,6 @@ using MediaBrowser.Providers.Lyric; using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Plugins.Tmdb; using MediaBrowser.Providers.Subtitles; -using MediaBrowser.Providers.Trickplay; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -593,7 +591,6 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 8ec24522b0..d1fbea95ac 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -26,7 +26,6 @@ using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Extensions; -using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; @@ -48,7 +47,6 @@ namespace Emby.Server.Implementations.Data { private const string FromText = " from TypedBaseItems A"; private const string ChaptersTableName = "Chapters2"; - private const string TrickplayTableName = "Trickplay"; private const string SaveItemCommandText = @"replace into TypedBaseItems @@ -384,8 +382,6 @@ namespace Emby.Server.Implementations.Data "create table if not exists " + ChaptersTableName + " (ItemId GUID, ChapterIndex INT NOT NULL, StartPositionTicks BIGINT NOT NULL, Name TEXT, ImagePath TEXT, PRIMARY KEY (ItemId, ChapterIndex))", - "create table if not exists " + TrickplayTableName + " (ItemId GUID, Width INT NOT NULL, Height INT NOT NULL, TileWidth INT NOT NULL, TileHeight INT NOT NULL, TileCount INT NOT NULL, Interval INT NOT NULL, Bandwidth INT NOT NULL, PRIMARY KEY (ItemId, Width))", - CreateMediaStreamsTableCommand, CreateMediaAttachmentsTableCommand, @@ -2138,126 +2134,6 @@ namespace Emby.Server.Implementations.Data } } - /// - public Dictionary GetTilesResolutions(Guid itemId) - { - CheckDisposed(); - - var tilesResolutions = new Dictionary(); - using (var connection = GetConnection(true)) - { - using (var statement = PrepareStatement(connection, "select Width,Height,TileWidth,TileHeight,TileCount,Interval,Bandwidth from " + TrickplayTableName + " where ItemId = @ItemId order by Width asc")) - { - statement.TryBind("@ItemId", itemId); - - foreach (var row in statement.ExecuteQuery()) - { - TrickplayTilesInfo tilesInfo = GetTrickplayTilesInfo(row); - tilesResolutions[tilesInfo.Width] = tilesInfo; - } - } - } - - return tilesResolutions; - } - - /// - public void SaveTilesInfo(Guid itemId, TrickplayTilesInfo tilesInfo) - { - CheckDisposed(); - - ArgumentNullException.ThrowIfNull(tilesInfo); - - var idBlob = itemId.ToByteArray(); - using (var connection = GetConnection(false)) - { - connection.RunInTransaction( - db => - { - // Delete old tiles info - db.Execute("delete from " + TrickplayTableName + " where ItemId=@ItemId and Width=@Width", idBlob, tilesInfo.Width); - db.Execute( - "insert into " + TrickplayTableName + " values (@ItemId, @Width, @Height, @TileWidth, @TileHeight, @TileCount, @Interval, @Bandwidth)", - idBlob, - tilesInfo.Width, - tilesInfo.Height, - tilesInfo.TileWidth, - tilesInfo.TileHeight, - tilesInfo.TileCount, - tilesInfo.Interval, - tilesInfo.Bandwidth); - }, - TransactionMode); - } - } - - /// - public Dictionary> GetTrickplayManifest(BaseItem item) - { - CheckDisposed(); - - var trickplayManifest = new Dictionary>(); - foreach (var mediaSource in item.GetMediaSources(false)) - { - var mediaSourceId = Guid.Parse(mediaSource.Id); - var tilesResolutions = GetTilesResolutions(mediaSourceId); - - if (tilesResolutions.Count > 0) - { - trickplayManifest[mediaSourceId] = tilesResolutions; - } - } - - return trickplayManifest; - } - - /// - /// Gets the trickplay tiles info. - /// - /// The reader. - /// TrickplayTilesInfo. - private TrickplayTilesInfo GetTrickplayTilesInfo(IReadOnlyList reader) - { - var tilesInfo = new TrickplayTilesInfo(); - - if (reader.TryGetInt32(0, out var width)) - { - tilesInfo.Width = width; - } - - if (reader.TryGetInt32(1, out var height)) - { - tilesInfo.Height = height; - } - - if (reader.TryGetInt32(2, out var tileWidth)) - { - tilesInfo.TileWidth = tileWidth; - } - - if (reader.TryGetInt32(3, out var tileHeight)) - { - tilesInfo.TileHeight = tileHeight; - } - - if (reader.TryGetInt32(4, out var tileCount)) - { - tilesInfo.TileCount = tileCount; - } - - if (reader.TryGetInt32(5, out var interval)) - { - tilesInfo.Interval = interval; - } - - if (reader.TryGetInt32(6, out var bandwidth)) - { - tilesInfo.Bandwidth = bandwidth; - } - - return tilesInfo; - } - private static bool EnableJoinUserData(InternalItemsQuery query) { if (query.User is null) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 1687fa442b..933b95df04 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -22,6 +22,7 @@ using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -52,6 +53,7 @@ namespace Emby.Server.Implementations.Dto private readonly Lazy _livetvManagerFactory; private readonly ILyricManager _lyricManager; + private readonly ITrickplayManager _trickplayManager; public DtoService( ILogger logger, @@ -63,7 +65,8 @@ namespace Emby.Server.Implementations.Dto IApplicationHost appHost, IMediaSourceManager mediaSourceManager, Lazy livetvManagerFactory, - ILyricManager lyricManager) + ILyricManager lyricManager, + ITrickplayManager trickplayManager) { _logger = logger; _libraryManager = libraryManager; @@ -75,6 +78,7 @@ namespace Emby.Server.Implementations.Dto _mediaSourceManager = mediaSourceManager; _livetvManagerFactory = livetvManagerFactory; _lyricManager = lyricManager; + _trickplayManager = trickplayManager; } private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; @@ -1060,9 +1064,11 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.Trickplay)) { + var manifest = _trickplayManager.GetTrickplayManifest(item).ConfigureAwait(false).GetAwaiter().GetResult(); + // To stay consistent with other fields, this must go from a Guid to a non-dashed string. // This does not seem to occur automatically to dictionaries like it does with other Guid fields. - dto.Trickplay = _itemRepo.GetTrickplayManifest(item).ToDictionary(x => x.Key.ToString("N", CultureInfo.InvariantCulture), y => y.Value); + dto.Trickplay = manifest.ToDictionary(x => x.Key.ToString("N", CultureInfo.InvariantCulture), y => y.Value); } if (video.ExtraType.HasValue) diff --git a/Jellyfin.Api/Controllers/TrickplayController.cs b/Jellyfin.Api/Controllers/TrickplayController.cs index 36464d726e..e4f8f076e4 100644 --- a/Jellyfin.Api/Controllers/TrickplayController.cs +++ b/Jellyfin.Api/Controllers/TrickplayController.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel.DataAnnotations; using System.Net.Mime; using System.Text; +using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; using MediaBrowser.Controller.Library; @@ -42,18 +43,18 @@ public class TrickplayController : BaseJellyfinApiController /// The item id. /// The width of a single tile. /// The media version id, if using an alternate version. - /// Tiles stream returned. - /// A containing the trickplay tiles file. + /// Tiles playlist returned. + /// A containing the trickplay playlist file. [HttpGet("Videos/{itemId}/Trickplay/{width}/tiles.m3u8")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesPlaylistFile] - public ActionResult GetTrickplayHlsPlaylist( + public async Task GetTrickplayHlsPlaylist( [FromRoute, Required] Guid itemId, [FromRoute, Required] int width, [FromQuery] Guid? mediaSourceId) { - string? playlist = _trickplayManager.GetHlsPlaylist(mediaSourceId ?? itemId, width, User.GetToken()); + string? playlist = await _trickplayManager.GetHlsPlaylist(mediaSourceId ?? itemId, width, User.GetToken()).ConfigureAwait(false); if (string.IsNullOrEmpty(playlist)) { @@ -64,20 +65,20 @@ public class TrickplayController : BaseJellyfinApiController } /// - /// Gets a trickplay tile grid image. + /// Gets a trickplay tile image. /// /// The item id. /// The width of a single tile. - /// The index of the desired tile grid. + /// The index of the desired tile. /// The media version id, if using an alternate version. - /// Tiles image returned. - /// Tiles image not found at specified index. + /// Tile image returned. + /// Tile image not found at specified index. /// A containing the trickplay tiles image. [HttpGet("Videos/{itemId}/Trickplay/{width}/{index}.jpg")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] - public ActionResult GetTrickplayGridImage( + public ActionResult GetTrickplayTileImage( [FromRoute, Required] Guid itemId, [FromRoute, Required] int width, [FromRoute, Required] int index, diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index b1657aeae2..bd30910558 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -308,8 +308,8 @@ public class DynamicHlsHelper if (!isLiveStream && (state.VideoRequest?.EnableTrickplay).GetValueOrDefault(false)) { var sourceId = Guid.Parse(state.Request.MediaSourceId); - var tilesResolutions = _trickplayManager.GetTilesResolutions(sourceId); - AddTrickplay(state, tilesResolutions, builder, _httpContextAccessor.HttpContext.User); + var trickplayResolutions = await _trickplayManager.GetTrickplayResolutions(sourceId).ConfigureAwait(false); + AddTrickplay(state, trickplayResolutions, builder, _httpContextAccessor.HttpContext.User); } return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); @@ -544,17 +544,17 @@ public class DynamicHlsHelper /// Appends EXT-X-IMAGE-STREAM-INF playlists for each available trickplay resolution. /// /// StreamState of the current stream. - /// Dictionary of widths to corresponding tiles info. + /// Dictionary of widths to corresponding tiles info. /// StringBuilder to append the field to. /// Http user context. - private void AddTrickplay(StreamState state, Dictionary tilesResolutions, StringBuilder builder, ClaimsPrincipal user) + private void AddTrickplay(StreamState state, Dictionary trickplayResolutions, StringBuilder builder, ClaimsPrincipal user) { const string playlistFormat = "#EXT-X-IMAGE-STREAM-INF:BANDWIDTH={0},RESOLUTION={1}x{2},CODECS=\"jpeg\",URI=\"{3}\""; - foreach (var resolution in tilesResolutions) + foreach (var resolution in trickplayResolutions) { var width = resolution.Key; - var tilesInfo = resolution.Value; + var trickplayInfo = resolution.Value; var url = string.Format( CultureInfo.InvariantCulture, @@ -566,9 +566,9 @@ public class DynamicHlsHelper var line = string.Format( CultureInfo.InvariantCulture, playlistFormat, - tilesInfo.Bandwidth.ToString(CultureInfo.InvariantCulture), - tilesInfo.Width.ToString(CultureInfo.InvariantCulture), - tilesInfo.Height.ToString(CultureInfo.InvariantCulture), + trickplayInfo.Bandwidth.ToString(CultureInfo.InvariantCulture), + trickplayInfo.Width.ToString(CultureInfo.InvariantCulture), + trickplayInfo.Height.ToString(CultureInfo.InvariantCulture), url); builder.AppendLine(line); diff --git a/Jellyfin.Data/Entities/TrickplayInfo.cs b/Jellyfin.Data/Entities/TrickplayInfo.cs new file mode 100644 index 0000000000..64e7da1b5d --- /dev/null +++ b/Jellyfin.Data/Entities/TrickplayInfo.cs @@ -0,0 +1,75 @@ +using System; +using System.Text.Json.Serialization; + +namespace Jellyfin.Data.Entities; + +/// +/// An entity representing the metadata for a group of trickplay tiles. +/// +public class TrickplayInfo +{ + /// + /// Gets or sets the id of the associated item. + /// + /// + /// Required. + /// + [JsonIgnore] + public Guid ItemId { get; set; } + + /// + /// Gets or sets width of an individual thumbnail. + /// + /// + /// Required. + /// + public int Width { get; set; } + + /// + /// Gets or sets height of an individual thumbnail. + /// + /// + /// Required. + /// + public int Height { get; set; } + + /// + /// Gets or sets amount of thumbnails per row. + /// + /// + /// Required. + /// + public int TileWidth { get; set; } + + /// + /// Gets or sets amount of thumbnails per column. + /// + /// + /// Required. + /// + public int TileHeight { get; set; } + + /// + /// Gets or sets total amount of non-black thumbnails. + /// + /// + /// Required. + /// + public int ThumbnailCount { get; set; } + + /// + /// Gets or sets interval in milliseconds between each trickplay thumbnail. + /// + /// + /// Required. + /// + public int Interval { get; set; } + + /// + /// Gets or sets peak bandwith usage in bits per second. + /// + /// + /// Required. + /// + public int Bandwidth { get; set; } +} diff --git a/Jellyfin.Server.Implementations/JellyfinDbContext.cs b/Jellyfin.Server.Implementations/JellyfinDbContext.cs index 0d91707e3e..ea99af0047 100644 --- a/Jellyfin.Server.Implementations/JellyfinDbContext.cs +++ b/Jellyfin.Server.Implementations/JellyfinDbContext.cs @@ -78,6 +78,11 @@ public class JellyfinDbContext : DbContext /// public DbSet Users => Set(); + /// + /// Gets the containing the trickplay metadata. + /// + public DbSet TrickplayInfos => Set(); + /*public DbSet Artwork => Set(); public DbSet Books => Set(); diff --git a/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs new file mode 100644 index 0000000000..28baf19925 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs @@ -0,0 +1,681 @@ +// +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20230626233818_AddTrickplayInfos")] + partial class AddTrickplayInfos + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.7"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs b/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs new file mode 100644 index 0000000000..76b12de083 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class AddTrickplayInfos : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TrickplayInfos", + columns: table => new + { + ItemId = table.Column(type: "TEXT", nullable: false), + Width = table.Column(type: "INTEGER", nullable: false), + Height = table.Column(type: "INTEGER", nullable: false), + TileWidth = table.Column(type: "INTEGER", nullable: false), + TileHeight = table.Column(type: "INTEGER", nullable: false), + ThumbnailCount = table.Column(type: "INTEGER", nullable: false), + Interval = table.Column(type: "INTEGER", nullable: false), + Bandwidth = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrickplayInfos", x => new { x.ItemId, x.Width }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrickplayInfos"); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index d23508096f..3c06e1cfc9 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Jellyfin.Server.Implementations; using Microsoft.EntityFrameworkCore; @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.7"); modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => { @@ -442,6 +442,37 @@ namespace Jellyfin.Server.Implementations.Migrations b.ToTable("DeviceOptions"); }); + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => { b.Property("Id") diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs new file mode 100644 index 0000000000..dc1c17e5ef --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs @@ -0,0 +1,18 @@ +using Jellyfin.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration +{ + /// + /// FluentAPI configuration for the TrickplayInfo entity. + /// + public class TrickplayInfoConfiguration : IEntityTypeConfiguration + { + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(info => new { info.ItemId, info.Width }); + } + } +} diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs new file mode 100644 index 0000000000..34b27e21a8 --- /dev/null +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -0,0 +1,468 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Implementations.Trickplay; + +/// +/// ITrickplayManager implementation. +/// +public class TrickplayManager : ITrickplayManager +{ + private readonly ILogger _logger; + private readonly IMediaEncoder _mediaEncoder; + private readonly IFileSystem _fileSystem; + private readonly EncodingHelper _encodingHelper; + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _config; + private readonly IImageEncoder _imageEncoder; + private readonly IDbContextFactory _dbProvider; + + private static readonly SemaphoreSlim _resourcePool = new(1, 1); + private static readonly string[] _trickplayImgExtensions = { ".jpg" }; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The media encoder. + /// The file systen. + /// The encoding helper. + /// The library manager. + /// The server configuration manager. + /// The image encoder. + /// The database provider. + public TrickplayManager( + ILogger logger, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + EncodingHelper encodingHelper, + ILibraryManager libraryManager, + IServerConfigurationManager config, + IImageEncoder imageEncoder, + IDbContextFactory dbProvider) + { + _logger = logger; + _mediaEncoder = mediaEncoder; + _fileSystem = fileSystem; + _encodingHelper = encodingHelper; + _libraryManager = libraryManager; + _config = config; + _imageEncoder = imageEncoder; + _dbProvider = dbProvider; + } + + /// + public async Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken) + { + _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace); + + var options = _config.Configuration.TrickplayOptions; + foreach (var width in options.WidthResolutions) + { + cancellationToken.ThrowIfCancellationRequested(); + await RefreshTrickplayDataInternal( + video, + replace, + width, + options, + cancellationToken).ConfigureAwait(false); + } + } + + private async Task RefreshTrickplayDataInternal( + Video video, + bool replace, + int width, + TrickplayOptions options, + CancellationToken cancellationToken) + { + if (!CanGenerateTrickplay(video, options.Interval)) + { + return; + } + + var imgTempDir = string.Empty; + var outputDir = GetTrickplayDirectory(video, width); + + await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width)) + { + _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id); + return; + } + + // Extract images + // Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay. + var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id)); + + if (mediaSource is null) + { + _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id); + return; + } + + var mediaPath = mediaSource.Path; + var mediaStream = mediaSource.VideoStream; + var container = mediaSource.Container; + + _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id); + imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated( + mediaPath, + container, + mediaSource, + mediaStream, + width, + TimeSpan.FromMilliseconds(options.Interval), + options.EnableHwAcceleration, + options.ProcessThreads, + options.Qscale, + options.ProcessPriority, + _encodingHelper, + cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir)) + { + throw new InvalidOperationException("Null or invalid directory from media encoder."); + } + + var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false) + .Select(i => i.FullName) + .OrderBy(i => i) + .ToList(); + + // Create tiles + var tilesTempDir = Path.Combine(imgTempDir, Guid.NewGuid().ToString("N")); + var trickplayInfo = CreateTiles(images, width, options, tilesTempDir, outputDir); + + // Save tiles info + try + { + if (trickplayInfo is not null) + { + trickplayInfo.ItemId = video.Id; + await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false); + + _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath); + } + else + { + throw new InvalidOperationException("Null trickplay tiles info from CreateTiles."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while saving trickplay tiles info."); + + // Make sure no files stay in metadata folders on failure + // if tiles info wasn't saved. + Directory.Delete(outputDir, true); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating trickplay images."); + } + finally + { + _resourcePool.Release(); + + if (!string.IsNullOrEmpty(imgTempDir)) + { + Directory.Delete(imgTempDir, true); + } + } + } + + private TrickplayInfo CreateTiles(List images, int width, TrickplayOptions options, string workDir, string outputDir) + { + if (images.Count == 0) + { + throw new ArgumentException("Can't create trickplay from 0 images."); + } + + Directory.CreateDirectory(workDir); + + var trickplayInfo = new TrickplayInfo + { + Width = width, + Interval = options.Interval, + TileWidth = options.TileWidth, + TileHeight = options.TileHeight, + ThumbnailCount = images.Count, + // Set during image generation + Height = 0, + Bandwidth = 0 + }; + + /* + * Generate trickplay tiles from sets of thumbnails + */ + var imageOptions = new ImageCollageOptions + { + Width = trickplayInfo.TileWidth, + Height = trickplayInfo.TileHeight + }; + + var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight; + var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile); + + for (int i = 0; i < requiredTiles; i++) + { + // Set output/input paths + var tilePath = Path.Combine(workDir, $"{i}.jpg"); + + imageOptions.OutputPath = tilePath; + imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(thumbnailsPerTile).ToList(); + + // Generate image and use returned height for tiles info + var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trickplayInfo.Height != 0 ? trickplayInfo.Height : null); + if (trickplayInfo.Height == 0) + { + trickplayInfo.Height = height; + } + + // Update bitrate + var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tilePath).Length * 8 / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000)); + trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate); + } + + /* + * Move trickplay tiles to output directory + */ + Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName); + + // Replace existing tiles if they already exist + if (Directory.Exists(outputDir)) + { + Directory.Delete(outputDir, true); + } + + MoveDirectory(workDir, outputDir); + + return trickplayInfo; + } + + private bool CanGenerateTrickplay(Video video, int interval) + { + var videoType = video.VideoType; + if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay) + { + return false; + } + + if (video.IsPlaceHolder) + { + return false; + } + + if (video.IsShortcut) + { + return false; + } + + if (!video.IsCompleteMedia) + { + return false; + } + + if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks) + { + return false; + } + + var libraryOptions = _libraryManager.GetLibraryOptions(video); + if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction) + { + return false; + } + + // Can't extract images if there are no video streams + return video.GetMediaStreams().Count > 0; + } + + /// + public async Task> GetTrickplayResolutions(Guid itemId) + { + var trickplayResolutions = new Dictionary(); + + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var trickplayInfos = await dbContext.TrickplayInfos + .AsNoTracking() + .Where(i => i.ItemId.Equals(itemId)) + .ToListAsync() + .ConfigureAwait(false); + + foreach (var info in trickplayInfos) + { + trickplayResolutions[info.Width] = info; + } + } + + return trickplayResolutions; + } + + /// + public async Task SaveTrickplayInfo(TrickplayInfo info) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var oldInfo = await dbContext.TrickplayInfos.FindAsync(info.ItemId, info.Width).ConfigureAwait(false); + if (oldInfo is not null) + { + dbContext.TrickplayInfos.Remove(oldInfo); + } + + dbContext.Add(info); + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } + + /// + public async Task>> GetTrickplayManifest(BaseItem item) + { + var trickplayManifest = new Dictionary>(); + foreach (var mediaSource in item.GetMediaSources(false)) + { + var mediaSourceId = Guid.Parse(mediaSource.Id); + var trickplayResolutions = await GetTrickplayResolutions(mediaSourceId).ConfigureAwait(false); + + if (trickplayResolutions.Count > 0) + { + trickplayManifest[mediaSourceId] = trickplayResolutions; + } + } + + return trickplayManifest; + } + + /// + public string GetTrickplayTilePath(BaseItem item, int width, int index) + { + return Path.Combine(GetTrickplayDirectory(item, width), index + ".jpg"); + } + + /// + public async Task GetHlsPlaylist(Guid itemId, int width, string? apiKey) + { + var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false); + if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo)) + { + var builder = new StringBuilder(128); + + if (trickplayInfo.ThumbnailCount > 0) + { + const string urlFormat = "Trickplay/{0}/{1}.jpg?MediaSourceId={2}&api_key={3}"; + const string decimalFormat = "{0:0.###}"; + + var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}"; + var layout = $"{trickplayInfo.TileWidth}x{trickplayInfo.TileHeight}"; + var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight; + var thumbnailDuration = trickplayInfo.Interval / 1000d; + var infDuration = thumbnailDuration * thumbnailsPerTile; + var tileCount = (int)Math.Ceiling((decimal)trickplayInfo.ThumbnailCount / thumbnailsPerTile); + + builder + .AppendLine("#EXTM3U") + .Append("#EXT-X-TARGETDURATION:") + .AppendLine(tileCount.ToString(CultureInfo.InvariantCulture)) + .AppendLine("#EXT-X-VERSION:7") + .AppendLine("#EXT-X-MEDIA-SEQUENCE:1") + .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD") + .AppendLine("#EXT-X-IMAGES-ONLY"); + + for (int i = 0; i < tileCount; i++) + { + // All tiles prior to the last must contain full amount of thumbnails (no black). + if (i == tileCount - 1) + { + thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile); + infDuration = thumbnailDuration * thumbnailsPerTile; + } + + // EXTINF + builder + .Append("#EXTINF:") + .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration) + .AppendLine(","); + + // EXT-X-TILES + builder + .Append("#EXT-X-TILES:RESOLUTION=") + .Append(resolution) + .Append(",LAYOUT=") + .Append(layout) + .Append(",DURATION=") + .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration) + .AppendLine(); + + // URL + builder + .AppendFormat( + CultureInfo.InvariantCulture, + urlFormat, + width.ToString(CultureInfo.InvariantCulture), + i.ToString(CultureInfo.InvariantCulture), + itemId.ToString("N"), + apiKey) + .AppendLine(); + } + + builder.AppendLine("#EXT-X-ENDLIST"); + return builder.ToString(); + } + } + + return null; + } + + private string GetTrickplayDirectory(BaseItem item, int? width = null) + { + var path = Path.Combine(item.GetInternalMetadataPath(), "trickplay"); + + return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path; + } + + private void MoveDirectory(string source, string destination) + { + try + { + Directory.Move(source, destination); + } + catch (IOException) + { + // Cross device move requires a copy + Directory.CreateDirectory(destination); + foreach (string file in Directory.GetFiles(source)) + { + File.Copy(file, Path.Join(destination, Path.GetFileName(file)), true); + } + + Directory.Delete(source, true); + } + } +} diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 939376dd8d..18d924aa8f 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -11,6 +11,7 @@ using Jellyfin.Server.Implementations.Activity; using Jellyfin.Server.Implementations.Devices; using Jellyfin.Server.Implementations.Events; using Jellyfin.Server.Implementations.Security; +using Jellyfin.Server.Implementations.Trickplay; using Jellyfin.Server.Implementations.Users; using MediaBrowser.Controller; using MediaBrowser.Controller.BaseItemManager; @@ -21,6 +22,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; +using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Activity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -77,6 +79,7 @@ namespace Jellyfin.Server serviceCollection.AddSingleton(); serviceCollection.AddScoped(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); // TODO search the assemblies instead of adding them manually? serviceCollection.AddSingleton(); diff --git a/MediaBrowser.Controller/Drawing/IImageEncoder.cs b/MediaBrowser.Controller/Drawing/IImageEncoder.cs index 42c680761d..c7bfbdb534 100644 --- a/MediaBrowser.Controller/Drawing/IImageEncoder.cs +++ b/MediaBrowser.Controller/Drawing/IImageEncoder.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Drawing; namespace MediaBrowser.Controller.Drawing @@ -84,13 +83,13 @@ namespace MediaBrowser.Controller.Drawing void CreateSplashscreen(IReadOnlyList posters, IReadOnlyList backdrops); /// - /// Creates a new jpeg trickplay grid image. + /// Creates a new trickplay tile image. /// - /// The options to use when creating the image. Width and Height are a quantity of tiles in this case, not pixels. + /// The options to use when creating the image. Width and Height are a quantity of thumbnails in this case, not pixels. /// The image encode quality. - /// The width of a single trickplay image. - /// Optional height of a single trickplay image, if it is known. - /// Height of single decoded trickplay image. - int CreateTrickplayGrid(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight); + /// The width of a single trickplay thumbnail. + /// Optional height of a single trickplay thumbnail, if it is known. + /// Height of single decoded trickplay thumbnail. + int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight); } } diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 11eb4932c9..2c52b2b45e 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -61,27 +61,6 @@ namespace MediaBrowser.Controller.Persistence /// The list of chapters to save. void SaveChapters(Guid id, IReadOnlyList chapters); - /// - /// Get available trickplay resolutions and corresponding info. - /// - /// The item. - /// Map of width resolutions to trickplay tiles info. - Dictionary GetTilesResolutions(Guid itemId); - - /// - /// Saves trickplay tiles info. - /// - /// The item. - /// The trickplay tiles info. - void SaveTilesInfo(Guid itemId, TrickplayTilesInfo tilesInfo); - - /// - /// Gets trickplay data for an item. - /// - /// The item. - /// A map of media source id to a map of tile width to tile info. - Dictionary> GetTrickplayManifest(BaseItem item); - /// /// Gets the media streams. /// diff --git a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs index 8d36fc3ff9..0a1e780b2e 100644 --- a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs +++ b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Entities; namespace MediaBrowser.Controller.Trickplay; @@ -13,7 +13,7 @@ namespace MediaBrowser.Controller.Trickplay; public interface ITrickplayManager { /// - /// Generate or replace trickplay data. + /// Generates new trickplay images and metadata. /// /// The video. /// Whether or not existing data should be replaced. @@ -26,28 +26,28 @@ public interface ITrickplayManager /// /// The item. /// Map of width resolutions to trickplay tiles info. - Dictionary GetTilesResolutions(Guid itemId); + Task> GetTrickplayResolutions(Guid itemId); /// - /// Saves trickplay tiles info. + /// Saves trickplay info. /// - /// The item. - /// The trickplay tiles info. - void SaveTilesInfo(Guid itemId, TrickplayTilesInfo tilesInfo); + /// The trickplay info. + /// Task. + Task SaveTrickplayInfo(TrickplayInfo info); /// - /// Gets the trickplay manifest. + /// Gets all trickplay infos for all media streams of an item. /// /// The item. - /// A map of media source id to a map of tile width to tile info. - Dictionary> GetTrickplayManifest(BaseItem item); + /// A map of media source id to a map of tile width to trickplay info. + Task>> GetTrickplayManifest(BaseItem item); /// - /// Gets the path to a trickplay tiles image. + /// Gets the path to a trickplay tile image. /// /// The item. - /// The width of a single tile. - /// The tile grid's index. + /// The width of a single thumbnail. + /// The tile's index. /// The absolute path. string GetTrickplayTilePath(BaseItem item, int width, int index); @@ -55,8 +55,8 @@ public interface ITrickplayManager /// Gets the trickplay HLS playlist. /// /// The item. - /// The width of a single tile. + /// The width of a single thumbnail. /// Optional api key of the requesting user. /// The text content of the .m3u8 playlist. - string? GetHlsPlaylist(Guid itemId, int width, string? apiKey); + Task GetHlsPlaylist(Guid itemId, int width, string? apiKey); } diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index 3db9cb08b5..287966dd0e 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Entities; @@ -572,7 +573,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the trickplay manifest. /// /// The trickplay manifest. - public Dictionary> Trickplay { get; set; } + public Dictionary> Trickplay { get; set; } /// /// Gets or sets the type of the location. diff --git a/MediaBrowser.Model/Entities/TrickplayTilesInfo.cs b/MediaBrowser.Model/Entities/TrickplayTilesInfo.cs deleted file mode 100644 index 86d37787f3..0000000000 --- a/MediaBrowser.Model/Entities/TrickplayTilesInfo.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace MediaBrowser.Model.Entities; - -/// -/// Class TrickplayTilesInfo. -/// -public class TrickplayTilesInfo -{ - /// - /// Gets or sets width of an individual tile. - /// - /// The width. - public int Width { get; set; } - - /// - /// Gets or sets height of an individual tile. - /// - /// The height. - public int Height { get; set; } - - /// - /// Gets or sets amount of tiles per row. - /// - /// The tile grid's width. - public int TileWidth { get; set; } - - /// - /// Gets or sets amount of tiles per column. - /// - /// The tile grid's height. - public int TileHeight { get; set; } - - /// - /// Gets or sets total amount of non-black tiles. - /// - /// The tile count. - public int TileCount { get; set; } - - /// - /// Gets or sets interval in milliseconds between each trickplay tile. - /// - /// The interval. - public int Interval { get; set; } - - /// - /// Gets or sets peak bandwith usage in bits per second. - /// - /// The bandwidth. - public int Bandwidth { get; set; } -} diff --git a/MediaBrowser.Providers/Trickplay/TrickplayManager.cs b/MediaBrowser.Providers/Trickplay/TrickplayManager.cs deleted file mode 100644 index 3ea7c00d07..0000000000 --- a/MediaBrowser.Providers/Trickplay/TrickplayManager.cs +++ /dev/null @@ -1,425 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Controller.Trickplay; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Providers.Trickplay; - -/// -/// ITrickplayManager implementation. -/// -public class TrickplayManager : ITrickplayManager -{ - private readonly ILogger _logger; - private readonly IItemRepository _itemRepo; - private readonly IMediaEncoder _mediaEncoder; - private readonly IFileSystem _fileSystem; - private readonly EncodingHelper _encodingHelper; - private readonly ILibraryManager _libraryManager; - private readonly IServerConfigurationManager _config; - private readonly IImageEncoder _imageEncoder; - - private static readonly SemaphoreSlim _resourcePool = new(1, 1); - private static readonly string[] _trickplayImgExtensions = { ".jpg" }; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The item repository. - /// The media encoder. - /// The file systen. - /// The encoding helper. - /// The library manager. - /// The server configuration manager. - /// The image encoder. - public TrickplayManager( - ILogger logger, - IItemRepository itemRepo, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - EncodingHelper encodingHelper, - ILibraryManager libraryManager, - IServerConfigurationManager config, - IImageEncoder imageEncoder) - { - _logger = logger; - _itemRepo = itemRepo; - _mediaEncoder = mediaEncoder; - _fileSystem = fileSystem; - _encodingHelper = encodingHelper; - _libraryManager = libraryManager; - _config = config; - _imageEncoder = imageEncoder; - } - - /// - public async Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken) - { - _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace); - - var options = _config.Configuration.TrickplayOptions; - foreach (var width in options.WidthResolutions) - { - cancellationToken.ThrowIfCancellationRequested(); - await RefreshTrickplayDataInternal( - video, - replace, - width, - options, - cancellationToken).ConfigureAwait(false); - } - } - - private async Task RefreshTrickplayDataInternal( - Video video, - bool replace, - int width, - TrickplayOptions options, - CancellationToken cancellationToken) - { - if (!CanGenerateTrickplay(video, options.Interval)) - { - return; - } - - var imgTempDir = string.Empty; - var outputDir = GetTrickplayDirectory(video, width); - - await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); - - try - { - if (!replace && Directory.Exists(outputDir) && GetTilesResolutions(video.Id).ContainsKey(width)) - { - _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id); - return; - } - - // Extract images - // Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay. - var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id)); - - if (mediaSource is null) - { - _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id); - return; - } - - var mediaPath = mediaSource.Path; - var mediaStream = mediaSource.VideoStream; - var container = mediaSource.Container; - - _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id); - imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated( - mediaPath, - container, - mediaSource, - mediaStream, - width, - TimeSpan.FromMilliseconds(options.Interval), - options.EnableHwAcceleration, - options.ProcessThreads, - options.Qscale, - options.ProcessPriority, - _encodingHelper, - cancellationToken).ConfigureAwait(false); - - if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir)) - { - throw new InvalidOperationException("Null or invalid directory from media encoder."); - } - - var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false) - .Select(i => i.FullName) - .OrderBy(i => i) - .ToList(); - - // Create tiles - var tilesTempDir = Path.Combine(imgTempDir, Guid.NewGuid().ToString("N")); - var tilesInfo = CreateTiles(images, width, options, tilesTempDir, outputDir); - - // Save tiles info - try - { - if (tilesInfo is not null) - { - SaveTilesInfo(video.Id, tilesInfo); - _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath); - } - else - { - throw new InvalidOperationException("Null trickplay tiles info from CreateTiles."); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while saving trickplay tiles info."); - - // Make sure no files stay in metadata folders on failure - // if tiles info wasn't saved. - Directory.Delete(outputDir, true); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating trickplay images."); - } - finally - { - _resourcePool.Release(); - - if (!string.IsNullOrEmpty(imgTempDir)) - { - Directory.Delete(imgTempDir, true); - } - } - } - - private TrickplayTilesInfo CreateTiles(List images, int width, TrickplayOptions options, string workDir, string outputDir) - { - if (images.Count == 0) - { - throw new ArgumentException("Can't create trickplay from 0 images."); - } - - Directory.CreateDirectory(workDir); - - var tilesInfo = new TrickplayTilesInfo - { - Width = width, - Interval = options.Interval, - TileWidth = options.TileWidth, - TileHeight = options.TileHeight, - TileCount = images.Count, - // Set during image generation - Height = 0, - Bandwidth = 0 - }; - - /* - * Generate trickplay tile grids from sets of images - */ - var imageOptions = new ImageCollageOptions - { - Width = tilesInfo.TileWidth, - Height = tilesInfo.TileHeight - }; - - var tilesPerGrid = tilesInfo.TileWidth * tilesInfo.TileHeight; - var requiredTileGrids = (int)Math.Ceiling((double)images.Count / tilesPerGrid); - - for (int i = 0; i < requiredTileGrids; i++) - { - // Set output/input paths - var tileGridPath = Path.Combine(workDir, $"{i}.jpg"); - - imageOptions.OutputPath = tileGridPath; - imageOptions.InputPaths = images.Skip(i * tilesPerGrid).Take(tilesPerGrid).ToList(); - - // Generate image and use returned height for tiles info - var height = _imageEncoder.CreateTrickplayGrid(imageOptions, options.JpegQuality, tilesInfo.Width, tilesInfo.Height != 0 ? tilesInfo.Height : null); - if (tilesInfo.Height == 0) - { - tilesInfo.Height = height; - } - - // Update bitrate - var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tileGridPath).Length * 8 / tilesInfo.TileWidth / tilesInfo.TileHeight / (tilesInfo.Interval / 1000)); - tilesInfo.Bandwidth = Math.Max(tilesInfo.Bandwidth, bitrate); - } - - /* - * Move trickplay tiles to output directory - */ - Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName); - - // Replace existing tile grids if they already exist - if (Directory.Exists(outputDir)) - { - Directory.Delete(outputDir, true); - } - - MoveDirectory(workDir, outputDir); - - return tilesInfo; - } - - private bool CanGenerateTrickplay(Video video, int interval) - { - var videoType = video.VideoType; - if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay) - { - return false; - } - - if (video.IsPlaceHolder) - { - return false; - } - - if (video.IsShortcut) - { - return false; - } - - if (!video.IsCompleteMedia) - { - return false; - } - - if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks) - { - return false; - } - - var libraryOptions = _libraryManager.GetLibraryOptions(video); - if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction) - { - return false; - } - - // Can't extract images if there are no video streams - return video.GetMediaStreams().Count > 0; - } - - /// - public Dictionary GetTilesResolutions(Guid itemId) - { - return _itemRepo.GetTilesResolutions(itemId); - } - - /// - public void SaveTilesInfo(Guid itemId, TrickplayTilesInfo tilesInfo) - { - _itemRepo.SaveTilesInfo(itemId, tilesInfo); - } - - /// - public Dictionary> GetTrickplayManifest(BaseItem item) - { - return _itemRepo.GetTrickplayManifest(item); - } - - /// - public string GetTrickplayTilePath(BaseItem item, int width, int index) - { - return Path.Combine(GetTrickplayDirectory(item, width), index + ".jpg"); - } - - /// - public string? GetHlsPlaylist(Guid itemId, int width, string? apiKey) - { - var tilesResolutions = GetTilesResolutions(itemId); - if (tilesResolutions is not null && tilesResolutions.TryGetValue(width, out var tilesInfo)) - { - var builder = new StringBuilder(128); - - if (tilesInfo.TileCount > 0) - { - const string urlFormat = "Trickplay/{0}/{1}.jpg?MediaSourceId={2}&api_key={3}"; - const string decimalFormat = "{0:0.###}"; - - var resolution = $"{tilesInfo.Width}x{tilesInfo.Height}"; - var layout = $"{tilesInfo.TileWidth}x{tilesInfo.TileHeight}"; - var tilesPerGrid = tilesInfo.TileWidth * tilesInfo.TileHeight; - var tileDuration = tilesInfo.Interval / 1000d; - var infDuration = tileDuration * tilesPerGrid; - var tileGridCount = (int)Math.Ceiling((decimal)tilesInfo.TileCount / tilesPerGrid); - - builder - .AppendLine("#EXTM3U") - .Append("#EXT-X-TARGETDURATION:") - .AppendLine(tileGridCount.ToString(CultureInfo.InvariantCulture)) - .AppendLine("#EXT-X-VERSION:7") - .AppendLine("#EXT-X-MEDIA-SEQUENCE:1") - .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD") - .AppendLine("#EXT-X-IMAGES-ONLY"); - - for (int i = 0; i < tileGridCount; i++) - { - // All tile grids before the last one must contain full amount of tiles. - // The final grid will be 0 < count <= maxTiles - if (i == tileGridCount - 1) - { - tilesPerGrid = tilesInfo.TileCount - (i * tilesPerGrid); - infDuration = tileDuration * tilesPerGrid; - } - - // EXTINF - builder - .Append("#EXTINF:") - .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration) - .AppendLine(","); - - // EXT-X-TILES - builder - .Append("#EXT-X-TILES:RESOLUTION=") - .Append(resolution) - .Append(",LAYOUT=") - .Append(layout) - .Append(",DURATION=") - .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, tileDuration) - .AppendLine(); - - // URL - builder - .AppendFormat( - CultureInfo.InvariantCulture, - urlFormat, - width.ToString(CultureInfo.InvariantCulture), - i.ToString(CultureInfo.InvariantCulture), - itemId.ToString("N"), - apiKey) - .AppendLine(); - } - - builder.AppendLine("#EXT-X-ENDLIST"); - return builder.ToString(); - } - } - - return null; - } - - private string GetTrickplayDirectory(BaseItem item, int? width = null) - { - var path = Path.Combine(item.GetInternalMetadataPath(), "trickplay"); - - return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path; - } - - private void MoveDirectory(string source, string destination) - { - try - { - Directory.Move(source, destination); - } - catch (IOException) - { - // Cross device move requires a copy - Directory.CreateDirectory(destination); - foreach (string file in Directory.GetFiles(source)) - { - File.Copy(file, Path.Join(destination, Path.GetFileName(file)), true); - } - - Directory.Delete(source, true); - } - } -} diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 2facf0f370..b387c437b8 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -531,7 +531,7 @@ public class SkiaEncoder : IImageEncoder } /// - public int CreateTrickplayGrid(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight) + public int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight) { var paths = options.InputPaths; var tileWidth = options.Width; diff --git a/src/Jellyfin.Drawing/NullImageEncoder.cs b/src/Jellyfin.Drawing/NullImageEncoder.cs index 15345e1bc8..1495661c12 100644 --- a/src/Jellyfin.Drawing/NullImageEncoder.cs +++ b/src/Jellyfin.Drawing/NullImageEncoder.cs @@ -50,7 +50,7 @@ public class NullImageEncoder : IImageEncoder } /// - public int CreateTrickplayGrid(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight) + public int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight) { throw new NotImplementedException(); } -- cgit v1.2.3 From fc619337481e3bb0f686aee06d9c5630926baaef Mon Sep 17 00:00:00 2001 From: Nick <20588554+nicknsy@users.noreply.github.com> Date: Mon, 26 Jun 2023 22:04:39 -0700 Subject: Minor code changes --- Emby.Server.Implementations/Dto/DtoService.cs | 2 +- MediaBrowser.Model/Configuration/ServerConfiguration.cs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 933b95df04..19ae396628 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1064,7 +1064,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.Trickplay)) { - var manifest = _trickplayManager.GetTrickplayManifest(item).ConfigureAwait(false).GetAwaiter().GetResult(); + var manifest = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult(); // To stay consistent with other fields, this must go from a Guid to a non-dashed string. // This does not seem to occur automatically to dictionaries like it does with other Guid fields. diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index 097eff295a..18f107503e 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -265,6 +265,10 @@ namespace MediaBrowser.Model.Configuration /// The limit for parallel image encoding. public int ParallelImageEncodingLimit { get; set; } + /// + /// Gets or sets the trickplay options. + /// + /// The trickplay options. public TrickplayOptions TrickplayOptions { get; set; } = new TrickplayOptions(); } } -- cgit v1.2.3 From 5a860710a849b9d67d12731f1a616c4a3904487d Mon Sep 17 00:00:00 2001 From: Nick <20588554+nicknsy@users.noreply.github.com> Date: Mon, 4 Sep 2023 12:30:20 -0700 Subject: Make TrickplayManifest dictionary key a string rather than Guid --- Emby.Server.Implementations/Dto/DtoService.cs | 6 +----- Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs | 6 +++--- MediaBrowser.Controller/Trickplay/ITrickplayManager.cs | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 19ae396628..be361c4d1c 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1064,11 +1064,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.Trickplay)) { - var manifest = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult(); - - // To stay consistent with other fields, this must go from a Guid to a non-dashed string. - // This does not seem to occur automatically to dictionaries like it does with other Guid fields. - dto.Trickplay = manifest.ToDictionary(x => x.Key.ToString("N", CultureInfo.InvariantCulture), y => y.Value); + dto.Trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult(); } if (video.ExtraType.HasValue) diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index e3bfdd50d6..107f8e5100 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -349,9 +349,9 @@ public class TrickplayManager : ITrickplayManager } /// - public async Task>> GetTrickplayManifest(BaseItem item) + public async Task>> GetTrickplayManifest(BaseItem item) { - var trickplayManifest = new Dictionary>(); + var trickplayManifest = new Dictionary>(); foreach (var mediaSource in item.GetMediaSources(false)) { var mediaSourceId = Guid.Parse(mediaSource.Id); @@ -359,7 +359,7 @@ public class TrickplayManager : ITrickplayManager if (trickplayResolutions.Count > 0) { - trickplayManifest[mediaSourceId] = trickplayResolutions; + trickplayManifest[mediaSource.Id] = trickplayResolutions; } } diff --git a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs index 7bea54fbab..0c41f30235 100644 --- a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs +++ b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs @@ -54,7 +54,7 @@ public interface ITrickplayManager /// /// The item. /// A map of media source id to a map of tile width to trickplay info. - Task>> GetTrickplayManifest(BaseItem item); + Task>> GetTrickplayManifest(BaseItem item); /// /// Gets the path to a trickplay tile image. -- cgit v1.2.3