Migrate to trickplay table to EF. Rename vars/methods/members to have consistent use of tile and thumbnail

pull/9554/head
Nick 10 months ago
parent a2a144869d
commit ab20ceaad6

@ -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<IDeviceDiscovery, DeviceDiscovery>();
serviceCollection.AddSingleton<IChapterManager, ChapterManager>();
serviceCollection.AddSingleton<ITrickplayManager, TrickplayManager>();
serviceCollection.AddSingleton<IEncodingManager, MediaEncoder.EncodingManager>();

@ -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
}
}
/// <inheritdoc />
public Dictionary<int, TrickplayTilesInfo> GetTilesResolutions(Guid itemId)
{
CheckDisposed();
var tilesResolutions = new Dictionary<int, TrickplayTilesInfo>();
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;
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
public Dictionary<Guid, Dictionary<int, TrickplayTilesInfo>> GetTrickplayManifest(BaseItem item)
{
CheckDisposed();
var trickplayManifest = new Dictionary<Guid, Dictionary<int, TrickplayTilesInfo>>();
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;
}
/// <summary>
/// Gets the trickplay tiles info.
/// </summary>
/// <param name="reader">The reader.</param>
/// <returns>TrickplayTilesInfo.</returns>
private TrickplayTilesInfo GetTrickplayTilesInfo(IReadOnlyList<ResultSetValue> 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)

@ -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<ILiveTvManager> _livetvManagerFactory;
private readonly ILyricManager _lyricManager;
private readonly ITrickplayManager _trickplayManager;
public DtoService(
ILogger<DtoService> logger,
@ -63,7 +65,8 @@ namespace Emby.Server.Implementations.Dto
IApplicationHost appHost,
IMediaSourceManager mediaSourceManager,
Lazy<ILiveTvManager> 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)

@ -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
/// <param name="itemId">The item id.</param>
/// <param name="width">The width of a single tile.</param>
/// <param name="mediaSourceId">The media version id, if using an alternate version.</param>
/// <response code="200">Tiles stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the trickplay tiles file.</returns>
/// <response code="200">Tiles playlist returned.</response>
/// <returns>A <see cref="FileResult"/> containing the trickplay playlist file.</returns>
[HttpGet("Videos/{itemId}/Trickplay/{width}/tiles.m3u8")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesPlaylistFile]
public ActionResult GetTrickplayHlsPlaylist(
public async Task<ActionResult> 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
}
/// <summary>
/// Gets a trickplay tile grid image.
/// Gets a trickplay tile image.
/// </summary>
/// <param name="itemId">The item id.</param>
/// <param name="width">The width of a single tile.</param>
/// <param name="index">The index of the desired tile grid.</param>
/// <param name="index">The index of the desired tile.</param>
/// <param name="mediaSourceId">The media version id, if using an alternate version.</param>
/// <response code="200">Tiles image returned.</response>
/// <response code="200">Tiles image not found at specified index.</response>
/// <response code="200">Tile image returned.</response>
/// <response code="200">Tile image not found at specified index.</response>
/// <returns>A <see cref="FileResult"/> containing the trickplay tiles image.</returns>
[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,

@ -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.
/// </summary>
/// <param name="state">StreamState of the current stream.</param>
/// <param name="tilesResolutions">Dictionary of widths to corresponding tiles info.</param>
/// <param name="trickplayResolutions">Dictionary of widths to corresponding tiles info.</param>
/// <param name="builder">StringBuilder to append the field to.</param>
/// <param name="user">Http user context.</param>
private void AddTrickplay(StreamState state, Dictionary<int, TrickplayTilesInfo> tilesResolutions, StringBuilder builder, ClaimsPrincipal user)
private void AddTrickplay(StreamState state, Dictionary<int, TrickplayInfo> 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);

@ -0,0 +1,75 @@
using System;
using System.Text.Json.Serialization;
namespace Jellyfin.Data.Entities;
/// <summary>
/// An entity representing the metadata for a group of trickplay tiles.
/// </summary>
public class TrickplayInfo
{
/// <summary>
/// Gets or sets the id of the associated item.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
[JsonIgnore]
public Guid ItemId { get; set; }
/// <summary>
/// Gets or sets width of an individual thumbnail.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public int Width { get; set; }
/// <summary>
/// Gets or sets height of an individual thumbnail.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public int Height { get; set; }
/// <summary>
/// Gets or sets amount of thumbnails per row.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public int TileWidth { get; set; }
/// <summary>
/// Gets or sets amount of thumbnails per column.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public int TileHeight { get; set; }
/// <summary>
/// Gets or sets total amount of non-black thumbnails.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public int ThumbnailCount { get; set; }
/// <summary>
/// Gets or sets interval in milliseconds between each trickplay thumbnail.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public int Interval { get; set; }
/// <summary>
/// Gets or sets peak bandwith usage in bits per second.
/// </summary>
/// <remarks>
/// Required.
/// </remarks>
public int Bandwidth { get; set; }
}

@ -78,6 +78,11 @@ public class JellyfinDbContext : DbContext
/// </summary>
public DbSet<User> Users => Set<User>();
/// <summary>
/// Gets the <see cref="DbSet{TEntity}"/> containing the trickplay metadata.
/// </summary>
public DbSet<TrickplayInfo> TrickplayInfos => Set<TrickplayInfo>();
/*public DbSet<Artwork> Artwork => Set<Artwork>();
public DbSet<Book> Books => Set<Book>();

@ -0,0 +1,681 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("DayOfWeek")
.HasColumnType("INTEGER");
b.Property<double>("EndHour")
.HasColumnType("REAL");
b.Property<double>("StartHour")
.HasColumnType("REAL");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AccessSchedules");
});
modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateCreated")
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("LogSeverity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("ShortOverview")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("DateCreated");
b.ToTable("ActivityLogs");
});
modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Client")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChromecastVersion")
.HasColumnType("INTEGER");
b.Property<string>("Client")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("DashboardTheme")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<bool>("EnableNextVideoInfoOverlay")
.HasColumnType("INTEGER");
b.Property<int?>("IndexBy")
.HasColumnType("INTEGER");
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<int>("ScrollDirection")
.HasColumnType("INTEGER");
b.Property<bool>("ShowBackdrop")
.HasColumnType("INTEGER");
b.Property<bool>("ShowSidebar")
.HasColumnType("INTEGER");
b.Property<int>("SkipBackwardLength")
.HasColumnType("INTEGER");
b.Property<int>("SkipForwardLength")
.HasColumnType("INTEGER");
b.Property<string>("TvHome")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId", "ItemId", "Client")
.IsUnique();
b.ToTable("DisplayPreferences");
});
modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("DisplayPreferencesId")
.HasColumnType("INTEGER");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("DisplayPreferencesId");
b.ToTable("HomeSection");
});
modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("ImageInfos");
});
modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Client")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<int?>("IndexBy")
.HasColumnType("INTEGER");
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<bool>("RememberIndexing")
.HasColumnType("INTEGER");
b.Property<bool>("RememberSorting")
.HasColumnType("INTEGER");
b.Property<string>("SortBy")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<int>("ViewType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("ItemDisplayPreferences");
});
modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<Guid?>("Permission_Permissions_Guid")
.HasColumnType("TEXT");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<Guid?>("Preference_Preferences_Guid")
.HasColumnType("TEXT");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("DateCreated")
.HasColumnType("TEXT");
b.Property<DateTime>("DateLastActivity")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AppName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("AppVersion")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("DateCreated")
.HasColumnType("TEXT");
b.Property<DateTime>("DateLastActivity")
.HasColumnType("TEXT");
b.Property<DateTime>("DateModified")
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<Guid>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CustomName")
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("DeviceId")
.IsUnique();
b.ToTable("DeviceOptions");
});
modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b =>
{
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<int>("Width")
.HasColumnType("INTEGER");
b.Property<int>("Bandwidth")
.HasColumnType("INTEGER");
b.Property<int>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Interval")
.HasColumnType("INTEGER");
b.Property<int>("ThumbnailCount")
.HasColumnType("INTEGER");
b.Property<int>("TileHeight")
.HasColumnType("INTEGER");
b.Property<int>("TileWidth")
.HasColumnType("INTEGER");
b.HasKey("ItemId", "Width");
b.ToTable("TrickplayInfos");
});
modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AudioLanguagePreference")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AuthenticationProviderId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("DisplayCollectionsView")
.HasColumnType("INTEGER");
b.Property<bool>("DisplayMissingEpisodes")
.HasColumnType("INTEGER");
b.Property<bool>("EnableAutoLogin")
.HasColumnType("INTEGER");
b.Property<bool>("EnableLocalPassword")
.HasColumnType("INTEGER");
b.Property<bool>("EnableNextEpisodeAutoPlay")
.HasColumnType("INTEGER");
b.Property<bool>("EnableUserPreferenceAccess")
.HasColumnType("INTEGER");
b.Property<bool>("HidePlayedInLatest")
.HasColumnType("INTEGER");
b.Property<long>("InternalId")
.HasColumnType("INTEGER");
b.Property<int>("InvalidLoginAttemptCount")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastActivityDate")
.HasColumnType("TEXT");
b.Property<DateTime?>("LastLoginDate")
.HasColumnType("TEXT");
b.Property<int?>("LoginAttemptsBeforeLockout")
.HasColumnType("INTEGER");
b.Property<int>("MaxActiveSessions")
.HasColumnType("INTEGER");
b.Property<int?>("MaxParentalAgeRating")
.HasColumnType("INTEGER");
b.Property<bool>("MustUpdatePassword")
.HasColumnType("INTEGER");
b.Property<string>("Password")
.HasMaxLength(65535)
.HasColumnType("TEXT");
b.Property<string>("PasswordResetProviderId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("PlayDefaultAudioTrack")
.HasColumnType("INTEGER");
b.Property<bool>("RememberAudioSelections")
.HasColumnType("INTEGER");
b.Property<bool>("RememberSubtitleSelections")
.HasColumnType("INTEGER");
b.Property<int?>("RemoteClientBitrateLimit")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SubtitleLanguagePreference")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<int>("SubtitleMode")
.HasColumnType("INTEGER");
b.Property<int>("SyncPlayAccess")
.HasColumnType("INTEGER");
b.Property<string>("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
}
}
}

@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class AddTrickplayInfos : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TrickplayInfos",
columns: table => new
{
ItemId = table.Column<Guid>(type: "TEXT", nullable: false),
Width = table.Column<int>(type: "INTEGER", nullable: false),
Height = table.Column<int>(type: "INTEGER", nullable: false),
TileWidth = table.Column<int>(type: "INTEGER", nullable: false),
TileHeight = table.Column<int>(type: "INTEGER", nullable: false),
ThumbnailCount = table.Column<int>(type: "INTEGER", nullable: false),
Interval = table.Column<int>(type: "INTEGER", nullable: false),
Bandwidth = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TrickplayInfos", x => new { x.ItemId, x.Width });
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TrickplayInfos");
}
}
}

@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
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<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<int>("Width")
.HasColumnType("INTEGER");
b.Property<int>("Bandwidth")
.HasColumnType("INTEGER");
b.Property<int>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Interval")
.HasColumnType("INTEGER");
b.Property<int>("ThumbnailCount")
.HasColumnType("INTEGER");
b.Property<int>("TileHeight")
.HasColumnType("INTEGER");
b.Property<int>("TileWidth")
.HasColumnType("INTEGER");
b.HasKey("ItemId", "Width");
b.ToTable("TrickplayInfos");
});
modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
{
b.Property<Guid>("Id")

@ -0,0 +1,18 @@
using Jellyfin.Data.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Jellyfin.Server.Implementations.ModelConfiguration
{
/// <summary>
/// FluentAPI configuration for the TrickplayInfo entity.
/// </summary>
public class TrickplayInfoConfiguration : IEntityTypeConfiguration<TrickplayInfo>
{
/// <inheritdoc/>
public void Configure(EntityTypeBuilder<TrickplayInfo> builder)
{
builder.HasKey(info => new { info.ItemId, info.Width });
}
}
}

@ -6,19 +6,20 @@ 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.Persistence;
using MediaBrowser.Controller.Trickplay;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Trickplay;
namespace Jellyfin.Server.Implementations.Trickplay;
/// <summary>
/// ITrickplayManager implementation.
@ -26,13 +27,13 @@ namespace MediaBrowser.Providers.Trickplay;
public class TrickplayManager : ITrickplayManager
{
private readonly ILogger<TrickplayManager> _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 readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private static readonly SemaphoreSlim _resourcePool = new(1, 1);
private static readonly string[] _trickplayImgExtensions = { ".jpg" };
@ -41,31 +42,31 @@ public class TrickplayManager : ITrickplayManager
/// Initializes a new instance of the <see cref="TrickplayManager"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="itemRepo">The item repository.</param>
/// <param name="mediaEncoder">The media encoder.</param>
/// <param name="fileSystem">The file systen.</param>
/// <param name="encodingHelper">The encoding helper.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="config">The server configuration manager.</param>
/// <param name="imageEncoder">The image encoder.</param>
/// <param name="dbProvider">The database provider.</param>
public TrickplayManager(
ILogger<TrickplayManager> logger,
IItemRepository itemRepo,
IMediaEncoder mediaEncoder,
IFileSystem fileSystem,
EncodingHelper encodingHelper,
ILibraryManager libraryManager,
IServerConfigurationManager config,
IImageEncoder imageEncoder)
IImageEncoder imageEncoder,
IDbContextFactory<JellyfinDbContext> dbProvider)
{
_logger = logger;
_itemRepo = itemRepo;
_mediaEncoder = mediaEncoder;
_fileSystem = fileSystem;
_encodingHelper = encodingHelper;
_libraryManager = libraryManager;
_config = config;
_imageEncoder = imageEncoder;
_dbProvider = dbProvider;
}
/// <inheritdoc />
@ -105,7 +106,7 @@ public class TrickplayManager : ITrickplayManager
try
{
if (!replace && Directory.Exists(outputDir) && GetTilesResolutions(video.Id).ContainsKey(width))
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;
@ -152,14 +153,16 @@ public class TrickplayManager : ITrickplayManager
// Create tiles
var tilesTempDir = Path.Combine(imgTempDir, Guid.NewGuid().ToString("N"));
var tilesInfo = CreateTiles(images, width, options, tilesTempDir, outputDir);
var trickplayInfo = CreateTiles(images, width, options, tilesTempDir, outputDir);
// Save tiles info
try
{
if (tilesInfo is not null)
if (trickplayInfo is not null)
{
SaveTilesInfo(video.Id, tilesInfo);
trickplayInfo.ItemId = video.Id;
await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
_logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
}
else
@ -191,7 +194,7 @@ public class TrickplayManager : ITrickplayManager
}
}
private TrickplayTilesInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string workDir, string outputDir)
private TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string workDir, string outputDir)
{
if (images.Count == 0)
{
@ -200,48 +203,48 @@ public class TrickplayManager : ITrickplayManager
Directory.CreateDirectory(workDir);
var tilesInfo = new TrickplayTilesInfo
var trickplayInfo = new TrickplayInfo
{
Width = width,
Interval = options.Interval,
TileWidth = options.TileWidth,
TileHeight = options.TileHeight,
TileCount = images.Count,
ThumbnailCount = images.Count,
// Set during image generation
Height = 0,
Bandwidth = 0
};
/*
* Generate trickplay tile grids from sets of images
* Generate trickplay tiles from sets of thumbnails
*/
var imageOptions = new ImageCollageOptions
{
Width = tilesInfo.TileWidth,
Height = tilesInfo.TileHeight
Width = trickplayInfo.TileWidth,
Height = trickplayInfo.TileHeight
};
var tilesPerGrid = tilesInfo.TileWidth * tilesInfo.TileHeight;
var requiredTileGrids = (int)Math.Ceiling((double)images.Count / tilesPerGrid);
var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile);
for (int i = 0; i < requiredTileGrids; i++)
for (int i = 0; i < requiredTiles; i++)
{
// Set output/input paths
var tileGridPath = Path.Combine(workDir, $"{i}.jpg");
var tilePath = Path.Combine(workDir, $"{i}.jpg");
imageOptions.OutputPath = tileGridPath;
imageOptions.InputPaths = images.Skip(i * tilesPerGrid).Take(tilesPerGrid).ToList();
imageOptions.OutputPath = tilePath;
imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(thumbnailsPerTile).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)
var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trickplayInfo.Height != 0 ? trickplayInfo.Height : null);
if (trickplayInfo.Height == 0)
{
tilesInfo.Height = height;
trickplayInfo.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);
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);
}
/*
@ -249,7 +252,7 @@ public class TrickplayManager : ITrickplayManager
*/
Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName);
// Replace existing tile grids if they already exist
// Replace existing tiles if they already exist
if (Directory.Exists(outputDir))
{
Directory.Delete(outputDir, true);
@ -257,7 +260,7 @@ public class TrickplayManager : ITrickplayManager
MoveDirectory(workDir, outputDir);
return tilesInfo;
return trickplayInfo;
}
private bool CanGenerateTrickplay(Video video, int interval)
@ -299,21 +302,62 @@ public class TrickplayManager : ITrickplayManager
}
/// <inheritdoc />
public Dictionary<int, TrickplayTilesInfo> GetTilesResolutions(Guid itemId)
public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId)
{
return _itemRepo.GetTilesResolutions(itemId);
var trickplayResolutions = new Dictionary<int, TrickplayInfo>();
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;
}
/// <inheritdoc />
public void SaveTilesInfo(Guid itemId, TrickplayTilesInfo tilesInfo)
public async Task SaveTrickplayInfo(TrickplayInfo info)
{
_itemRepo.SaveTilesInfo(itemId, tilesInfo);
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);
}
}
/// <inheritdoc />
public Dictionary<Guid, Dictionary<int, TrickplayTilesInfo>> GetTrickplayManifest(BaseItem item)
public async Task<Dictionary<Guid, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
{
return _itemRepo.GetTrickplayManifest(item);
var trickplayManifest = new Dictionary<Guid, Dictionary<int, TrickplayInfo>>();
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;
}
/// <inheritdoc />
@ -323,42 +367,41 @@ public class TrickplayManager : ITrickplayManager
}
/// <inheritdoc />
public string? GetHlsPlaylist(Guid itemId, int width, string? apiKey)
public async Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey)
{
var tilesResolutions = GetTilesResolutions(itemId);
if (tilesResolutions is not null && tilesResolutions.TryGetValue(width, out var tilesInfo))
var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false);
if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
{
var builder = new StringBuilder(128);
if (tilesInfo.TileCount > 0)
if (trickplayInfo.ThumbnailCount > 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);
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(tileGridCount.ToString(CultureInfo.InvariantCulture))
.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 < tileGridCount; i++)
for (int i = 0; i < tileCount; 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)
// All tiles prior to the last must contain full amount of thumbnails (no black).
if (i == tileCount - 1)
{
tilesPerGrid = tilesInfo.TileCount - (i * tilesPerGrid);
infDuration = tileDuration * tilesPerGrid;
thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile);
infDuration = thumbnailDuration * thumbnailsPerTile;
}
// EXTINF
@ -374,7 +417,7 @@ public class TrickplayManager : ITrickplayManager
.Append(",LAYOUT=")
.Append(layout)
.Append(",DURATION=")
.AppendFormat(CultureInfo.InvariantCulture, decimalFormat, tileDuration)
.AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration)
.AppendLine();
// URL

@ -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<IUserManager, UserManager>();
serviceCollection.AddScoped<IDisplayPreferencesManager, DisplayPreferencesManager>();
serviceCollection.AddSingleton<IDeviceManager, DeviceManager>();
serviceCollection.AddSingleton<ITrickplayManager, TrickplayManager>();
// TODO search the assemblies instead of adding them manually?
serviceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>();

@ -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<string> posters, IReadOnlyList<string> backdrops);
/// <summary>
/// Creates a new jpeg trickplay grid image.
/// Creates a new trickplay tile image.
/// </summary>
/// <param name="options">The options to use when creating the image. Width and Height are a quantity of tiles in this case, not pixels.</param>
/// <param name="options">The options to use when creating the image. Width and Height are a quantity of thumbnails in this case, not pixels.</param>
/// <param name="quality">The image encode quality.</param>
/// <param name="imgWidth">The width of a single trickplay image.</param>
/// <param name="imgHeight">Optional height of a single trickplay image, if it is known.</param>
/// <returns>Height of single decoded trickplay image.</returns>
int CreateTrickplayGrid(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight);
/// <param name="imgWidth">The width of a single trickplay thumbnail.</param>
/// <param name="imgHeight">Optional height of a single trickplay thumbnail, if it is known.</param>
/// <returns>Height of single decoded trickplay thumbnail.</returns>
int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight);
}
}

@ -61,27 +61,6 @@ namespace MediaBrowser.Controller.Persistence
/// <param name="chapters">The list of chapters to save.</param>
void SaveChapters(Guid id, IReadOnlyList<ChapterInfo> chapters);
/// <summary>
/// Get available trickplay resolutions and corresponding info.
/// </summary>
/// <param name="itemId">The item.</param>
/// <returns>Map of width resolutions to trickplay tiles info.</returns>
Dictionary<int, TrickplayTilesInfo> GetTilesResolutions(Guid itemId);
/// <summary>
/// Saves trickplay tiles info.
/// </summary>
/// <param name="itemId">The item.</param>
/// <param name="tilesInfo">The trickplay tiles info.</param>
void SaveTilesInfo(Guid itemId, TrickplayTilesInfo tilesInfo);
/// <summary>
/// Gets trickplay data for an item.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>A map of media source id to a map of tile width to tile info.</returns>
Dictionary<Guid, Dictionary<int, TrickplayTilesInfo>> GetTrickplayManifest(BaseItem item);
/// <summary>
/// Gets the media streams.
/// </summary>

@ -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
{
/// <summary>
/// Generate or replace trickplay data.
/// Generates new trickplay images and metadata.
/// </summary>
/// <param name="video">The video.</param>
/// <param name="replace">Whether or not existing data should be replaced.</param>
@ -26,28 +26,28 @@ public interface ITrickplayManager
/// </summary>
/// <param name="itemId">The item.</param>
/// <returns>Map of width resolutions to trickplay tiles info.</returns>
Dictionary<int, TrickplayTilesInfo> GetTilesResolutions(Guid itemId);
Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId);
/// <summary>
/// Saves trickplay tiles info.
/// Saves trickplay info.
/// </summary>
/// <param name="itemId">The item.</param>
/// <param name="tilesInfo">The trickplay tiles info.</param>
void SaveTilesInfo(Guid itemId, TrickplayTilesInfo tilesInfo);
/// <param name="info">The trickplay info.</param>
/// <returns>Task.</returns>
Task SaveTrickplayInfo(TrickplayInfo info);
/// <summary>
/// Gets the trickplay manifest.
/// Gets all trickplay infos for all media streams of an item.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>A map of media source id to a map of tile width to tile info.</returns>
Dictionary<Guid, Dictionary<int, TrickplayTilesInfo>> GetTrickplayManifest(BaseItem item);
/// <returns>A map of media source id to a map of tile width to trickplay info.</returns>
Task<Dictionary<Guid, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item);
/// <summary>
/// Gets the path to a trickplay tiles image.
/// Gets the path to a trickplay tile image.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="width">The width of a single tile.</param>
/// <param name="index">The tile grid's index.</param>
/// <param name="width">The width of a single thumbnail.</param>
/// <param name="index">The tile's index.</param>
/// <returns>The absolute path.</returns>
string GetTrickplayTilePath(BaseItem item, int width, int index);
@ -55,8 +55,8 @@ public interface ITrickplayManager
/// Gets the trickplay HLS playlist.
/// </summary>
/// <param name="itemId">The item.</param>
/// <param name="width">The width of a single tile.</param>
/// <param name="width">The width of a single thumbnail.</param>
/// <param name="apiKey">Optional api key of the requesting user.</param>
/// <returns>The text content of the .m3u8 playlist.</returns>
string? GetHlsPlaylist(Guid itemId, int width, string? apiKey);
Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey);
}

@ -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.
/// </summary>
/// <value>The trickplay manifest.</value>
public Dictionary<string, Dictionary<int, TrickplayTilesInfo>> Trickplay { get; set; }
public Dictionary<string, Dictionary<int, TrickplayInfo>> Trickplay { get; set; }
/// <summary>
/// Gets or sets the type of the location.

@ -1,49 +0,0 @@
namespace MediaBrowser.Model.Entities;
/// <summary>
/// Class TrickplayTilesInfo.
/// </summary>
public class TrickplayTilesInfo
{
/// <summary>
/// Gets or sets width of an individual tile.
/// </summary>
/// <value>The width.</value>
public int Width { get; set; }
/// <summary>
/// Gets or sets height of an individual tile.
/// </summary>
/// <value>The height.</value>
public int Height { get; set; }
/// <summary>
/// Gets or sets amount of tiles per row.
/// </summary>
/// <value>The tile grid's width.</value>
public int TileWidth { get; set; }
/// <summary>
/// Gets or sets amount of tiles per column.
/// </summary>
/// <value>The tile grid's height.</value>
public int TileHeight { get; set; }
/// <summary>
/// Gets or sets total amount of non-black tiles.
/// </summary>
/// <value>The tile count.</value>
public int TileCount { get; set; }
/// <summary>
/// Gets or sets interval in milliseconds between each trickplay tile.
/// </summary>
/// <value>The interval.</value>
public int Interval { get; set; }
/// <summary>
/// Gets or sets peak bandwith usage in bits per second.
/// </summary>
/// <value>The bandwidth.</value>
public int Bandwidth { get; set; }
}

@ -531,7 +531,7 @@ public class SkiaEncoder : IImageEncoder
}
/// <inheritdoc />
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;

@ -50,7 +50,7 @@ public class NullImageEncoder : IImageEncoder
}
/// <inheritdoc />
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();
}

Loading…
Cancel
Save