mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-07-09 03:04:24 -04:00
Merge pull request #9554 from nicknsy/trickplay
This commit is contained in:
commit
8859a3ac8e
@ -57,6 +57,7 @@
|
|||||||
- [hawken93](https://github.com/hawken93)
|
- [hawken93](https://github.com/hawken93)
|
||||||
- [HelloWorld017](https://github.com/HelloWorld017)
|
- [HelloWorld017](https://github.com/HelloWorld017)
|
||||||
- [ikomhoog](https://github.com/ikomhoog)
|
- [ikomhoog](https://github.com/ikomhoog)
|
||||||
|
- [iwalton3](https://github.com/iwalton3)
|
||||||
- [jftuga](https://github.com/jftuga)
|
- [jftuga](https://github.com/jftuga)
|
||||||
- [jmshrv](https://github.com/jmshrv)
|
- [jmshrv](https://github.com/jmshrv)
|
||||||
- [joern-h](https://github.com/joern-h)
|
- [joern-h](https://github.com/joern-h)
|
||||||
@ -88,6 +89,7 @@
|
|||||||
- [neilsb](https://github.com/neilsb)
|
- [neilsb](https://github.com/neilsb)
|
||||||
- [nevado](https://github.com/nevado)
|
- [nevado](https://github.com/nevado)
|
||||||
- [Nickbert7](https://github.com/Nickbert7)
|
- [Nickbert7](https://github.com/Nickbert7)
|
||||||
|
- [nicknsy](https://github.com/nicknsy)
|
||||||
- [nvllsvm](https://github.com/nvllsvm)
|
- [nvllsvm](https://github.com/nvllsvm)
|
||||||
- [nyanmisaka](https://github.com/nyanmisaka)
|
- [nyanmisaka](https://github.com/nyanmisaka)
|
||||||
- [OancaAndrei](https://github.com/OancaAndrei)
|
- [OancaAndrei](https://github.com/OancaAndrei)
|
||||||
|
@ -22,6 +22,7 @@ using MediaBrowser.Controller.Lyrics;
|
|||||||
using MediaBrowser.Controller.Persistence;
|
using MediaBrowser.Controller.Persistence;
|
||||||
using MediaBrowser.Controller.Playlists;
|
using MediaBrowser.Controller.Playlists;
|
||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Controller.Trickplay;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Model.Querying;
|
using MediaBrowser.Model.Querying;
|
||||||
@ -52,6 +53,7 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
private readonly Lazy<ILiveTvManager> _livetvManagerFactory;
|
private readonly Lazy<ILiveTvManager> _livetvManagerFactory;
|
||||||
|
|
||||||
private readonly ILyricManager _lyricManager;
|
private readonly ILyricManager _lyricManager;
|
||||||
|
private readonly ITrickplayManager _trickplayManager;
|
||||||
|
|
||||||
public DtoService(
|
public DtoService(
|
||||||
ILogger<DtoService> logger,
|
ILogger<DtoService> logger,
|
||||||
@ -63,7 +65,8 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
IApplicationHost appHost,
|
IApplicationHost appHost,
|
||||||
IMediaSourceManager mediaSourceManager,
|
IMediaSourceManager mediaSourceManager,
|
||||||
Lazy<ILiveTvManager> livetvManagerFactory,
|
Lazy<ILiveTvManager> livetvManagerFactory,
|
||||||
ILyricManager lyricManager)
|
ILyricManager lyricManager,
|
||||||
|
ITrickplayManager trickplayManager)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
@ -75,6 +78,7 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
_mediaSourceManager = mediaSourceManager;
|
_mediaSourceManager = mediaSourceManager;
|
||||||
_livetvManagerFactory = livetvManagerFactory;
|
_livetvManagerFactory = livetvManagerFactory;
|
||||||
_lyricManager = lyricManager;
|
_lyricManager = lyricManager;
|
||||||
|
_trickplayManager = trickplayManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
|
private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
|
||||||
@ -1059,6 +1063,11 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
dto.Chapters = _itemRepo.GetChapters(item);
|
dto.Chapters = _itemRepo.GetChapters(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.ContainsField(ItemFields.Trickplay))
|
||||||
|
{
|
||||||
|
dto.Trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
if (video.ExtraType.HasValue)
|
if (video.ExtraType.HasValue)
|
||||||
{
|
{
|
||||||
dto.ExtraType = video.ExtraType.Value.ToString();
|
dto.ExtraType = video.ExtraType.Value.ToString();
|
||||||
|
@ -112,6 +112,8 @@
|
|||||||
"TaskCleanLogsDescription": "Deletes log files that are more than {0} days old.",
|
"TaskCleanLogsDescription": "Deletes log files that are more than {0} days old.",
|
||||||
"TaskRefreshPeople": "Refresh People",
|
"TaskRefreshPeople": "Refresh People",
|
||||||
"TaskRefreshPeopleDescription": "Updates metadata for actors and directors in your media library.",
|
"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",
|
"TaskUpdatePlugins": "Update Plugins",
|
||||||
"TaskUpdatePluginsDescription": "Downloads and installs updates for plugins that are configured to update automatically.",
|
"TaskUpdatePluginsDescription": "Downloads and installs updates for plugins that are configured to update automatically.",
|
||||||
"TaskCleanTranscode": "Clean Transcode Directory",
|
"TaskCleanTranscode": "Clean Transcode Directory",
|
||||||
|
@ -410,6 +410,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
||||||
/// <param name="streamOptions">Optional. The streaming options.</param>
|
/// <param name="streamOptions">Optional. The streaming options.</param>
|
||||||
/// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
|
/// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
|
||||||
|
/// <param name="enableTrickplay">Enable trickplay image playlists being added to master playlist.</param>
|
||||||
/// <response code="200">Video stream returned.</response>
|
/// <response code="200">Video stream returned.</response>
|
||||||
/// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
|
/// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
|
||||||
[HttpGet("Videos/{itemId}/master.m3u8")]
|
[HttpGet("Videos/{itemId}/master.m3u8")]
|
||||||
@ -467,7 +468,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
[FromQuery] EncodingContext? context,
|
[FromQuery] EncodingContext? context,
|
||||||
[FromQuery] Dictionary<string, string> streamOptions,
|
[FromQuery] Dictionary<string, string> streamOptions,
|
||||||
[FromQuery] bool enableAdaptiveBitrateStreaming = true)
|
[FromQuery] bool enableAdaptiveBitrateStreaming = true,
|
||||||
|
[FromQuery] bool enableTrickplay = true)
|
||||||
{
|
{
|
||||||
var streamingRequest = new HlsVideoRequestDto
|
var streamingRequest = new HlsVideoRequestDto
|
||||||
{
|
{
|
||||||
@ -521,7 +523,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
VideoStreamIndex = videoStreamIndex,
|
VideoStreamIndex = videoStreamIndex,
|
||||||
Context = context ?? EncodingContext.Streaming,
|
Context = context ?? EncodingContext.Streaming,
|
||||||
StreamOptions = streamOptions,
|
StreamOptions = streamOptions,
|
||||||
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
|
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming,
|
||||||
|
EnableTrickplay = enableTrickplay
|
||||||
};
|
};
|
||||||
|
|
||||||
return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
|
return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
|
||||||
|
101
Jellyfin.Api/Controllers/TrickplayController.cs
Normal file
101
Jellyfin.Api/Controllers/TrickplayController.cs
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
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;
|
||||||
|
using MediaBrowser.Controller.Trickplay;
|
||||||
|
using MediaBrowser.Model;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trickplay controller.
|
||||||
|
/// </summary>
|
||||||
|
[Route("")]
|
||||||
|
[Authorize]
|
||||||
|
public class TrickplayController : BaseJellyfinApiController
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly ITrickplayManager _trickplayManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TrickplayController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">Instance of <see cref="ILibraryManager"/>.</param>
|
||||||
|
/// <param name="trickplayManager">Instance of <see cref="ITrickplayManager"/>.</param>
|
||||||
|
public TrickplayController(
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
ITrickplayManager trickplayManager)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_trickplayManager = trickplayManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an image tiles playlist for trickplay.
|
||||||
|
/// </summary>
|
||||||
|
/// <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 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 async Task<ActionResult> GetTrickplayHlsPlaylist(
|
||||||
|
[FromRoute, Required] Guid itemId,
|
||||||
|
[FromRoute, Required] int width,
|
||||||
|
[FromQuery] Guid? mediaSourceId)
|
||||||
|
{
|
||||||
|
string? playlist = await _trickplayManager.GetHlsPlaylist(mediaSourceId ?? itemId, width, User.GetToken()).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(playlist))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Content(playlist, MimeTypes.GetMimeType("playlist.m3u8"), Encoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.</param>
|
||||||
|
/// <param name="mediaSourceId">The media version id, if using an alternate version.</param>
|
||||||
|
/// <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 GetTrickplayTileImage(
|
||||||
|
[FromRoute, Required] Guid itemId,
|
||||||
|
[FromRoute, Required] int width,
|
||||||
|
[FromRoute, Required] int index,
|
||||||
|
[FromQuery] Guid? mediaSourceId)
|
||||||
|
{
|
||||||
|
var item = _libraryManager.GetItemById(mediaSourceId ?? itemId);
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = _trickplayManager.GetTrickplayTilePath(item, width, index);
|
||||||
|
if (System.IO.File.Exists(path))
|
||||||
|
{
|
||||||
|
return PhysicalFile(path, MediaTypeNames.Image.Jpeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Api.Extensions;
|
using Jellyfin.Api.Extensions;
|
||||||
using Jellyfin.Api.Models.StreamingDtos;
|
using Jellyfin.Api.Models.StreamingDtos;
|
||||||
|
using Jellyfin.Data.Entities;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
@ -19,6 +20,7 @@ using MediaBrowser.Controller.Devices;
|
|||||||
using MediaBrowser.Controller.Dlna;
|
using MediaBrowser.Controller.Dlna;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.MediaEncoding;
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
|
using MediaBrowser.Controller.Trickplay;
|
||||||
using MediaBrowser.Model.Dlna;
|
using MediaBrowser.Model.Dlna;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Model.Net;
|
using MediaBrowser.Model.Net;
|
||||||
@ -46,6 +48,7 @@ public class DynamicHlsHelper
|
|||||||
private readonly ILogger<DynamicHlsHelper> _logger;
|
private readonly ILogger<DynamicHlsHelper> _logger;
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
private readonly EncodingHelper _encodingHelper;
|
private readonly EncodingHelper _encodingHelper;
|
||||||
|
private readonly ITrickplayManager _trickplayManager;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class.
|
/// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class.
|
||||||
@ -62,6 +65,7 @@ public class DynamicHlsHelper
|
|||||||
/// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param>
|
/// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param>
|
||||||
/// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
|
/// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
|
||||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||||
|
/// <param name="trickplayManager">Instance of <see cref="ITrickplayManager"/>.</param>
|
||||||
public DynamicHlsHelper(
|
public DynamicHlsHelper(
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
IUserManager userManager,
|
IUserManager userManager,
|
||||||
@ -74,7 +78,8 @@ public class DynamicHlsHelper
|
|||||||
INetworkManager networkManager,
|
INetworkManager networkManager,
|
||||||
ILogger<DynamicHlsHelper> logger,
|
ILogger<DynamicHlsHelper> logger,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
EncodingHelper encodingHelper)
|
EncodingHelper encodingHelper,
|
||||||
|
ITrickplayManager trickplayManager)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
@ -88,6 +93,7 @@ public class DynamicHlsHelper
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_httpContextAccessor = httpContextAccessor;
|
_httpContextAccessor = httpContextAccessor;
|
||||||
_encodingHelper = encodingHelper;
|
_encodingHelper = encodingHelper;
|
||||||
|
_trickplayManager = trickplayManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -280,6 +286,13 @@ public class DynamicHlsHelper
|
|||||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isLiveStream && (state.VideoRequest?.EnableTrickplay ?? false))
|
||||||
|
{
|
||||||
|
var sourceId = Guid.Parse(state.Request.MediaSourceId);
|
||||||
|
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"));
|
return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -508,6 +521,41 @@ public class DynamicHlsHelper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends EXT-X-IMAGE-STREAM-INF playlists for each available trickplay resolution.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="state">StreamState of the current stream.</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, 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 trickplayResolutions)
|
||||||
|
{
|
||||||
|
var width = resolution.Key;
|
||||||
|
var trickplayInfo = resolution.Value;
|
||||||
|
|
||||||
|
var url = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"Trickplay/{0}/tiles.m3u8?MediaSourceId={1}&api_key={2}",
|
||||||
|
width.ToString(CultureInfo.InvariantCulture),
|
||||||
|
state.Request.MediaSourceId,
|
||||||
|
user.GetToken());
|
||||||
|
|
||||||
|
builder.AppendFormat(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
playlistFormat,
|
||||||
|
trickplayInfo.Bandwidth.ToString(CultureInfo.InvariantCulture),
|
||||||
|
trickplayInfo.Width.ToString(CultureInfo.InvariantCulture),
|
||||||
|
trickplayInfo.Height.ToString(CultureInfo.InvariantCulture),
|
||||||
|
url);
|
||||||
|
|
||||||
|
builder.AppendLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the H.26X level of the output video stream.
|
/// Get the H.26X level of the output video stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
namespace Jellyfin.Api.Models.StreamingDtos;
|
namespace Jellyfin.Api.Models.StreamingDtos;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The video request dto.
|
/// The video request dto.
|
||||||
@ -15,4 +15,9 @@ public class VideoRequestDto : StreamingRequestDto
|
|||||||
/// Gets or sets a value indicating whether to enable subtitles in the manifest.
|
/// Gets or sets a value indicating whether to enable subtitles in the manifest.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool EnableSubtitlesInManifest { get; set; }
|
public bool EnableSubtitlesInManifest { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to enable trickplay images.
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableTrickplay { get; set; }
|
||||||
}
|
}
|
||||||
|
75
Jellyfin.Data/Entities/TrickplayInfo.cs
Normal file
75
Jellyfin.Data/Entities/TrickplayInfo.cs
Normal file
@ -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>
|
/// </summary>
|
||||||
public DbSet<User> Users => Set<User>();
|
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<Artwork> Artwork => Set<Artwork>();
|
||||||
|
|
||||||
public DbSet<Book> Books => Set<Book>();
|
public DbSet<Book> Books => Set<Book>();
|
||||||
|
681
Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs
generated
Normal file
681
Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs
generated
Normal file
@ -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 System;
|
||||||
using Jellyfin.Server.Implementations;
|
using Jellyfin.Server.Implementations;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@ -442,6 +442,37 @@ namespace Jellyfin.Server.Implementations.Migrations
|
|||||||
b.ToTable("DeviceOptions");
|
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 =>
|
modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
474
Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
Normal file
474
Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
Normal file
@ -0,0 +1,474 @@
|
|||||||
|
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.Common.Configuration;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ITrickplayManager implementation.
|
||||||
|
/// </summary>
|
||||||
|
public class TrickplayManager : ITrickplayManager
|
||||||
|
{
|
||||||
|
private readonly ILogger<TrickplayManager> _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<JellyfinDbContext> _dbProvider;
|
||||||
|
private readonly IApplicationPaths _appPaths;
|
||||||
|
|
||||||
|
private static readonly SemaphoreSlim _resourcePool = new(1, 1);
|
||||||
|
private static readonly string[] _trickplayImgExtensions = { ".jpg" };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TrickplayManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</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>
|
||||||
|
/// <param name="appPaths">The application paths.</param>
|
||||||
|
public TrickplayManager(
|
||||||
|
ILogger<TrickplayManager> logger,
|
||||||
|
IMediaEncoder mediaEncoder,
|
||||||
|
IFileSystem fileSystem,
|
||||||
|
EncodingHelper encodingHelper,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IServerConfigurationManager config,
|
||||||
|
IImageEncoder imageEncoder,
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
|
IApplicationPaths appPaths)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_mediaEncoder = mediaEncoder;
|
||||||
|
_fileSystem = fileSystem;
|
||||||
|
_encodingHelper = encodingHelper;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_config = config;
|
||||||
|
_imageEncoder = imageEncoder;
|
||||||
|
_dbProvider = dbProvider;
|
||||||
|
_appPaths = appPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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 trickplayInfo = CreateTiles(images, width, options, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string outputDir)
|
||||||
|
{
|
||||||
|
if (images.Count == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Can't create trickplay from 0 images.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var workDir = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid().ToString("N"));
|
||||||
|
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.GetRange(i * thumbnailsPerTile, Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile)));
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid 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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
|
||||||
|
{
|
||||||
|
var trickplayManifest = new Dictionary<string, 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[mediaSource.Id] = trickplayResolutions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trickplayManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string GetTrickplayTilePath(BaseItem item, int width, int index)
|
||||||
|
{
|
||||||
|
return Path.Combine(GetTrickplayDirectory(item, width), index + ".jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<string?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,7 @@ using Jellyfin.Server.Implementations.Activity;
|
|||||||
using Jellyfin.Server.Implementations.Devices;
|
using Jellyfin.Server.Implementations.Devices;
|
||||||
using Jellyfin.Server.Implementations.Events;
|
using Jellyfin.Server.Implementations.Events;
|
||||||
using Jellyfin.Server.Implementations.Security;
|
using Jellyfin.Server.Implementations.Security;
|
||||||
|
using Jellyfin.Server.Implementations.Trickplay;
|
||||||
using Jellyfin.Server.Implementations.Users;
|
using Jellyfin.Server.Implementations.Users;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Controller.BaseItemManager;
|
using MediaBrowser.Controller.BaseItemManager;
|
||||||
@ -21,6 +22,7 @@ using MediaBrowser.Controller.Library;
|
|||||||
using MediaBrowser.Controller.Lyrics;
|
using MediaBrowser.Controller.Lyrics;
|
||||||
using MediaBrowser.Controller.Net;
|
using MediaBrowser.Controller.Net;
|
||||||
using MediaBrowser.Controller.Security;
|
using MediaBrowser.Controller.Security;
|
||||||
|
using MediaBrowser.Controller.Trickplay;
|
||||||
using MediaBrowser.Model.Activity;
|
using MediaBrowser.Model.Activity;
|
||||||
using MediaBrowser.Providers.Lyric;
|
using MediaBrowser.Providers.Lyric;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@ -78,6 +80,7 @@ namespace Jellyfin.Server
|
|||||||
serviceCollection.AddSingleton<IUserManager, UserManager>();
|
serviceCollection.AddSingleton<IUserManager, UserManager>();
|
||||||
serviceCollection.AddScoped<IDisplayPreferencesManager, DisplayPreferencesManager>();
|
serviceCollection.AddScoped<IDisplayPreferencesManager, DisplayPreferencesManager>();
|
||||||
serviceCollection.AddSingleton<IDeviceManager, DeviceManager>();
|
serviceCollection.AddSingleton<IDeviceManager, DeviceManager>();
|
||||||
|
serviceCollection.AddSingleton<ITrickplayManager, TrickplayManager>();
|
||||||
|
|
||||||
// TODO search the assemblies instead of adding them manually?
|
// TODO search the assemblies instead of adding them manually?
|
||||||
serviceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>();
|
serviceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>();
|
||||||
|
@ -81,5 +81,15 @@ namespace MediaBrowser.Controller.Drawing
|
|||||||
/// <param name="posters">The list of poster paths.</param>
|
/// <param name="posters">The list of poster paths.</param>
|
||||||
/// <param name="backdrops">The list of backdrop paths.</param>
|
/// <param name="backdrops">The list of backdrop paths.</param>
|
||||||
void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops);
|
void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 thumbnails in this case, not pixels.</param>
|
||||||
|
/// <param name="quality">The image encode quality.</param>
|
||||||
|
/// <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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -100,6 +100,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
{ "truehd", 6 },
|
{ "truehd", 6 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly string _defaultMjpegEncoder = "mjpeg";
|
||||||
|
private static readonly Dictionary<string, string> _mjpegCodecMap = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
{ "vaapi", _defaultMjpegEncoder + "_vaapi" },
|
||||||
|
{ "qsv", _defaultMjpegEncoder + "_qsv" }
|
||||||
|
};
|
||||||
|
|
||||||
public static readonly string[] LosslessAudioCodecs = new string[]
|
public static readonly string[] LosslessAudioCodecs = new string[]
|
||||||
{
|
{
|
||||||
"alac",
|
"alac",
|
||||||
@ -167,6 +174,24 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
return defaultEncoder;
|
return defaultEncoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetMjpegEncoder(EncodingJobInfo state, EncodingOptions encodingOptions)
|
||||||
|
{
|
||||||
|
if (state.VideoType == VideoType.VideoFile)
|
||||||
|
{
|
||||||
|
var hwType = encodingOptions.HardwareAccelerationType;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(hwType)
|
||||||
|
&& encodingOptions.EnableHardwareEncoding
|
||||||
|
&& _mjpegCodecMap.TryGetValue(hwType, out var preferredEncoder)
|
||||||
|
&& _mediaEncoder.SupportsEncoder(preferredEncoder))
|
||||||
|
{
|
||||||
|
return preferredEncoder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _defaultMjpegEncoder;
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsVaapiSupported(EncodingJobInfo state)
|
private bool IsVaapiSupported(EncodingJobInfo state)
|
||||||
{
|
{
|
||||||
// vaapi will throw an error with this input
|
// vaapi will throw an error with this input
|
||||||
@ -300,6 +325,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
return GetH264Encoder(state, encodingOptions);
|
return GetH264Encoder(state, encodingOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.Equals(codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return GetMjpegEncoder(state, encodingOptions);
|
||||||
|
}
|
||||||
|
|
||||||
if (string.Equals(codec, "vp8", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(codec, "vp8", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(codec, "vpx", StringComparison.OrdinalIgnoreCase))
|
|| string.Equals(codec, "vpx", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@ -4917,6 +4947,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
subFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter));
|
subFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter));
|
||||||
overlayFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter));
|
overlayFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter));
|
||||||
|
|
||||||
|
var framerate = GetFramerateParam(state);
|
||||||
|
if (framerate.HasValue)
|
||||||
|
{
|
||||||
|
mainFilters.Insert(0, string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"fps={0}",
|
||||||
|
framerate.Value));
|
||||||
|
}
|
||||||
|
|
||||||
var mainStr = string.Empty;
|
var mainStr = string.Empty;
|
||||||
if (mainFilters?.Count > 0)
|
if (mainFilters?.Count > 0)
|
||||||
{
|
{
|
||||||
|
@ -4,8 +4,10 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
using MediaBrowser.Model.Dlna;
|
using MediaBrowser.Model.Dlna;
|
||||||
using MediaBrowser.Model.Drawing;
|
using MediaBrowser.Model.Drawing;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
@ -137,6 +139,36 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
/// <returns>Location of video image.</returns>
|
/// <returns>Location of video image.</returns>
|
||||||
Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, ImageFormat? targetFormat, CancellationToken cancellationToken);
|
Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, ImageFormat? targetFormat, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the video images on interval.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inputFile">Input file.</param>
|
||||||
|
/// <param name="container">Video container type.</param>
|
||||||
|
/// <param name="mediaSource">Media source information.</param>
|
||||||
|
/// <param name="imageStream">Media stream information.</param>
|
||||||
|
/// <param name="maxWidth">The maximum width.</param>
|
||||||
|
/// <param name="interval">The interval.</param>
|
||||||
|
/// <param name="allowHwAccel">Allow for hardware acceleration.</param>
|
||||||
|
/// <param name="threads">The input/output thread count for ffmpeg.</param>
|
||||||
|
/// <param name="qualityScale">The qscale value for ffmpeg.</param>
|
||||||
|
/// <param name="priority">The process priority for the ffmpeg process.</param>
|
||||||
|
/// <param name="encodingHelper">EncodingHelper instance.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>Directory where images where extracted. A given image made before another will always be named with a lower number.</returns>
|
||||||
|
Task<string> ExtractVideoImagesOnIntervalAccelerated(
|
||||||
|
string inputFile,
|
||||||
|
string container,
|
||||||
|
MediaSourceInfo mediaSource,
|
||||||
|
MediaStream imageStream,
|
||||||
|
int maxWidth,
|
||||||
|
TimeSpan interval,
|
||||||
|
bool allowHwAccel,
|
||||||
|
int? threads,
|
||||||
|
int? qualityScale,
|
||||||
|
ProcessPriorityClass? priority,
|
||||||
|
EncodingHelper encodingHelper,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the media info.
|
/// Gets the media info.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
76
MediaBrowser.Controller/Trickplay/ITrickplayManager.cs
Normal file
76
MediaBrowser.Controller/Trickplay/ITrickplayManager.cs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Trickplay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface ITrickplayManager.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITrickplayManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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>
|
||||||
|
/// <param name="cancellationToken">CancellationToken to use for operation.</param>
|
||||||
|
/// <returns>Task.</returns>
|
||||||
|
Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates trickplay tiles out of individual thumbnails.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="images">Ordered file paths of the thumbnails to be used.</param>
|
||||||
|
/// <param name="width">The width of a single thumbnail.</param>
|
||||||
|
/// <param name="options">The trickplay options.</param>
|
||||||
|
/// <param name="outputDir">The output directory.</param>
|
||||||
|
/// <returns>The associated trickplay information.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The output directory will be DELETED and replaced if it already exists.
|
||||||
|
/// </remarks>
|
||||||
|
TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string outputDir);
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves trickplay info.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="info">The trickplay info.</param>
|
||||||
|
/// <returns>Task.</returns>
|
||||||
|
Task SaveTrickplayInfo(TrickplayInfo info);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 trickplay info.</returns>
|
||||||
|
Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the path to a trickplay tile image.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item.</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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the trickplay HLS playlist.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The item.</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>
|
||||||
|
Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey);
|
||||||
|
}
|
@ -21,6 +21,7 @@ using MediaBrowser.Controller.Configuration;
|
|||||||
using MediaBrowser.Controller.Extensions;
|
using MediaBrowser.Controller.Extensions;
|
||||||
using MediaBrowser.Controller.MediaEncoding;
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
using MediaBrowser.MediaEncoding.Probing;
|
using MediaBrowser.MediaEncoding.Probing;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
using MediaBrowser.Model.Dlna;
|
using MediaBrowser.Model.Dlna;
|
||||||
using MediaBrowser.Model.Drawing;
|
using MediaBrowser.Model.Drawing;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
@ -28,8 +29,10 @@ using MediaBrowser.Model.Entities;
|
|||||||
using MediaBrowser.Model.Globalization;
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using MediaBrowser.Model.MediaInfo;
|
using MediaBrowser.Model.MediaInfo;
|
||||||
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using static Nikse.SubtitleEdit.Core.Common.IfoParser;
|
||||||
|
|
||||||
namespace MediaBrowser.MediaEncoding.Encoder
|
namespace MediaBrowser.MediaEncoding.Encoder
|
||||||
{
|
{
|
||||||
@ -781,6 +784,191 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
public Task<string> ExtractVideoImagesOnIntervalAccelerated(
|
||||||
|
string inputFile,
|
||||||
|
string container,
|
||||||
|
MediaSourceInfo mediaSource,
|
||||||
|
MediaStream imageStream,
|
||||||
|
int maxWidth,
|
||||||
|
TimeSpan interval,
|
||||||
|
bool allowHwAccel,
|
||||||
|
int? threads,
|
||||||
|
int? qualityScale,
|
||||||
|
ProcessPriorityClass? priority,
|
||||||
|
EncodingHelper encodingHelper,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var options = allowHwAccel ? _configurationManager.GetEncodingOptions() : new EncodingOptions();
|
||||||
|
threads ??= _threads;
|
||||||
|
|
||||||
|
// A new EncodingOptions instance must be used as to not disable HW acceleration for all of Jellyfin.
|
||||||
|
// Additionally, we must set a few fields without defaults to prevent null pointer exceptions.
|
||||||
|
if (!allowHwAccel)
|
||||||
|
{
|
||||||
|
options.EnableHardwareEncoding = false;
|
||||||
|
options.HardwareAccelerationType = string.Empty;
|
||||||
|
options.EnableTonemapping = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseRequest = new BaseEncodingJobOptions { MaxWidth = maxWidth, MaxFramerate = (float)(1.0 / interval.TotalSeconds) };
|
||||||
|
var jobState = new EncodingJobInfo(TranscodingJobType.Progressive)
|
||||||
|
{
|
||||||
|
IsVideoRequest = true, // must be true for InputVideoHwaccelArgs to return non-empty value
|
||||||
|
MediaSource = mediaSource,
|
||||||
|
VideoStream = imageStream,
|
||||||
|
BaseRequest = baseRequest, // GetVideoProcessingFilterParam errors if null
|
||||||
|
MediaPath = inputFile,
|
||||||
|
OutputVideoCodec = "mjpeg"
|
||||||
|
};
|
||||||
|
var vidEncoder = options.AllowMjpegEncoding ? encodingHelper.GetVideoEncoder(jobState, options) : jobState.OutputVideoCodec;
|
||||||
|
|
||||||
|
// Get input and filter arguments
|
||||||
|
var inputArg = encodingHelper.GetInputArgument(jobState, options, container).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(inputArg))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("EncodingHelper returned empty input arguments.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowHwAccel)
|
||||||
|
{
|
||||||
|
inputArg = "-threads " + threads + " " + inputArg; // HW accel args set a different input thread count, only set if disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, jobState.OutputVideoCodec).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(filterParam))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtractVideoImagesOnIntervalInternal(inputArg, filterParam, vidEncoder, threads, qualityScale, priority, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ExtractVideoImagesOnIntervalInternal(
|
||||||
|
string inputArg,
|
||||||
|
string filterParam,
|
||||||
|
string vidEncoder,
|
||||||
|
int? outputThreads,
|
||||||
|
int? qualityScale,
|
||||||
|
ProcessPriorityClass? priority,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(inputArg))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Empty or invalid input argument.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output arguments
|
||||||
|
var targetDirectory = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(targetDirectory);
|
||||||
|
var outputPath = Path.Combine(targetDirectory, "%08d.jpg");
|
||||||
|
|
||||||
|
// Final command arguments
|
||||||
|
var args = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"-loglevel error {0} -an -sn {1} -threads {2} -c:v {3} {4}-f {5} \"{6}\"",
|
||||||
|
inputArg,
|
||||||
|
filterParam,
|
||||||
|
outputThreads.GetValueOrDefault(_threads),
|
||||||
|
vidEncoder,
|
||||||
|
qualityScale.HasValue ? "-qscale:v " + qualityScale.Value.ToString(CultureInfo.InvariantCulture) + " " : string.Empty,
|
||||||
|
"image2",
|
||||||
|
outputPath);
|
||||||
|
|
||||||
|
// Start ffmpeg process
|
||||||
|
var process = new Process
|
||||||
|
{
|
||||||
|
StartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
FileName = _ffmpegPath,
|
||||||
|
Arguments = args,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden,
|
||||||
|
ErrorDialog = false,
|
||||||
|
},
|
||||||
|
EnableRaisingEvents = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var processDescription = string.Format(CultureInfo.InvariantCulture, "{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
||||||
|
_logger.LogInformation("Trickplay generation: {ProcessDescription}", processDescription);
|
||||||
|
|
||||||
|
using (var processWrapper = new ProcessWrapper(process, this))
|
||||||
|
{
|
||||||
|
bool ranToCompletion = false;
|
||||||
|
|
||||||
|
await _thumbnailResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
StartProcess(processWrapper);
|
||||||
|
|
||||||
|
// Set process priority
|
||||||
|
if (priority.HasValue)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
processWrapper.Process.PriorityClass = priority.Value;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Unable to set process priority to {Priority} for {Description}", priority.Value, processDescription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to give ffmpeg enough time to make all the thumbnails, which could be a while,
|
||||||
|
// but we still need to detect if the process hangs.
|
||||||
|
// Making the assumption that as long as new jpegs are showing up, everything is good.
|
||||||
|
|
||||||
|
bool isResponsive = true;
|
||||||
|
int lastCount = 0;
|
||||||
|
var timeoutMs = _configurationManager.Configuration.ImageExtractionTimeoutMs;
|
||||||
|
timeoutMs = timeoutMs <= 0 ? DefaultHdrImageExtractionTimeout : timeoutMs;
|
||||||
|
|
||||||
|
while (isResponsive)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(TimeSpan.FromMilliseconds(timeoutMs)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
ranToCompletion = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// We don't actually expect the process to be finished in one timeout span, just that one image has been generated.
|
||||||
|
}
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var jpegCount = _fileSystem.GetFilePaths(targetDirectory).Count();
|
||||||
|
|
||||||
|
isResponsive = jpegCount > lastCount;
|
||||||
|
lastCount = jpegCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ranToCompletion)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Stopping trickplay extraction due to process inactivity.");
|
||||||
|
StopProcess(processWrapper, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_thumbnailResourcePool.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1;
|
||||||
|
|
||||||
|
if (exitCode == -1)
|
||||||
|
{
|
||||||
|
_logger.LogError("ffmpeg image extraction failed for {ProcessDescription}", processDescription);
|
||||||
|
|
||||||
|
throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "ffmpeg image extraction failed for {0}", processDescription));
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public string GetTimeParameter(long ticks)
|
public string GetTimeParameter(long ticks)
|
||||||
{
|
{
|
||||||
var time = TimeSpan.FromTicks(ticks);
|
var time = TimeSpan.FromTicks(ticks);
|
||||||
|
@ -50,6 +50,7 @@ public class EncodingOptions
|
|||||||
EnableHardwareEncoding = true;
|
EnableHardwareEncoding = true;
|
||||||
AllowHevcEncoding = false;
|
AllowHevcEncoding = false;
|
||||||
AllowAv1Encoding = false;
|
AllowAv1Encoding = false;
|
||||||
|
AllowMjpegEncoding = false;
|
||||||
EnableSubtitleExtraction = true;
|
EnableSubtitleExtraction = true;
|
||||||
AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = new[] { "mkv" };
|
AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = new[] { "mkv" };
|
||||||
HardwareDecodingCodecs = new string[] { "h264", "vc1" };
|
HardwareDecodingCodecs = new string[] { "h264", "vc1" };
|
||||||
@ -255,6 +256,11 @@ public class EncodingOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AllowAv1Encoding { get; set; }
|
public bool AllowAv1Encoding { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether MJPEG encoding is enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool AllowMjpegEncoding { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether subtitle extraction is enabled.
|
/// Gets or sets a value indicating whether subtitle extraction is enabled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -35,6 +35,10 @@ namespace MediaBrowser.Model.Configuration
|
|||||||
|
|
||||||
public bool ExtractChapterImagesDuringLibraryScan { get; set; }
|
public bool ExtractChapterImagesDuringLibraryScan { get; set; }
|
||||||
|
|
||||||
|
public bool EnableTrickplayImageExtraction { get; set; }
|
||||||
|
|
||||||
|
public bool ExtractTrickplayImagesDuringLibraryScan { get; set; }
|
||||||
|
|
||||||
public MediaPathInfo[] PathInfos { get; set; }
|
public MediaPathInfo[] PathInfos { get; set; }
|
||||||
|
|
||||||
public bool SaveLocalMetadata { get; set; }
|
public bool SaveLocalMetadata { get; set; }
|
||||||
|
@ -270,4 +270,10 @@ public class ServerConfiguration : BaseApplicationConfiguration
|
|||||||
/// Gets or sets the list of cast receiver applications.
|
/// Gets or sets the list of cast receiver applications.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public CastReceiverApplication[] CastReceiverApplications { get; set; } = Array.Empty<CastReceiverApplication>();
|
public CastReceiverApplication[] CastReceiverApplications { get; set; } = Array.Empty<CastReceiverApplication>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the trickplay options.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The trickplay options.</value>
|
||||||
|
public TrickplayOptions TrickplayOptions { get; set; } = new TrickplayOptions();
|
||||||
}
|
}
|
||||||
|
60
MediaBrowser.Model/Configuration/TrickplayOptions.cs
Normal file
60
MediaBrowser.Model/Configuration/TrickplayOptions.cs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Class TrickplayOptions.
|
||||||
|
/// </summary>
|
||||||
|
public class TrickplayOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether or not to use HW acceleration.
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableHwAcceleration { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the behavior used by trickplay provider on library scan/update.
|
||||||
|
/// </summary>
|
||||||
|
public TrickplayScanBehavior ScanBehavior { get; set; } = TrickplayScanBehavior.NonBlocking;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the process priority for the ffmpeg process.
|
||||||
|
/// </summary>
|
||||||
|
public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the interval, in ms, between each new trickplay image.
|
||||||
|
/// </summary>
|
||||||
|
public int Interval { get; set; } = 10000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the target width resolutions, in px, to generates preview images for.
|
||||||
|
/// </summary>
|
||||||
|
public int[] WidthResolutions { get; set; } = new[] { 320 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets number of tile images to allow in X dimension.
|
||||||
|
/// </summary>
|
||||||
|
public int TileWidth { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets number of tile images to allow in Y dimension.
|
||||||
|
/// </summary>
|
||||||
|
public int TileHeight { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ffmpeg output quality level.
|
||||||
|
/// </summary>
|
||||||
|
public int Qscale { get; set; } = 4;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the jpeg quality to use for image tiles.
|
||||||
|
/// </summary>
|
||||||
|
public int JpegQuality { get; set; } = 90;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of threads to be used by ffmpeg.
|
||||||
|
/// </summary>
|
||||||
|
public int ProcessThreads { get; set; } = 1;
|
||||||
|
}
|
17
MediaBrowser.Model/Configuration/TrickplayScanBehavior.cs
Normal file
17
MediaBrowser.Model/Configuration/TrickplayScanBehavior.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
namespace MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enum TrickplayScanBehavior.
|
||||||
|
/// </summary>
|
||||||
|
public enum TrickplayScanBehavior
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Starts generation, only return once complete.
|
||||||
|
/// </summary>
|
||||||
|
Blocking,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Start generation, return immediately.
|
||||||
|
/// </summary>
|
||||||
|
NonBlocking
|
||||||
|
}
|
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using Jellyfin.Data.Entities;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using MediaBrowser.Model.Drawing;
|
using MediaBrowser.Model.Drawing;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
@ -568,6 +569,12 @@ namespace MediaBrowser.Model.Dto
|
|||||||
/// <value>The chapters.</value>
|
/// <value>The chapters.</value>
|
||||||
public List<ChapterInfo> Chapters { get; set; }
|
public List<ChapterInfo> Chapters { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the trickplay manifest.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The trickplay manifest.</value>
|
||||||
|
public Dictionary<string, Dictionary<int, TrickplayInfo>> Trickplay { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the type of the location.
|
/// Gets or sets the type of the location.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -34,6 +34,11 @@ namespace MediaBrowser.Model.Querying
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Chapters,
|
Chapters,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The trickplay manifest.
|
||||||
|
/// </summary>
|
||||||
|
Trickplay,
|
||||||
|
|
||||||
ChildCount,
|
ChildCount,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
118
MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs
Normal file
118
MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Trickplay;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Globalization;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TagLib.Ape;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Providers.Trickplay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Class TrickplayImagesTask.
|
||||||
|
/// </summary>
|
||||||
|
public class TrickplayImagesTask : IScheduledTask
|
||||||
|
{
|
||||||
|
private const int QueryPageLimit = 100;
|
||||||
|
|
||||||
|
private readonly ILogger<TrickplayImagesTask> _logger;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly ILocalizationManager _localization;
|
||||||
|
private readonly ITrickplayManager _trickplayManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TrickplayImagesTask"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
/// <param name="localization">The localization manager.</param>
|
||||||
|
/// <param name="trickplayManager">The trickplay manager.</param>
|
||||||
|
public TrickplayImagesTask(
|
||||||
|
ILogger<TrickplayImagesTask> logger,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
ILocalizationManager localization,
|
||||||
|
ITrickplayManager trickplayManager)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_logger = logger;
|
||||||
|
_localization = localization;
|
||||||
|
_trickplayManager = trickplayManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => _localization.GetLocalizedString("TaskRefreshTrickplayImages");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Description => _localization.GetLocalizedString("TaskRefreshTrickplayImagesDescription");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Key => "RefreshTrickplayImages";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new TaskTriggerInfo
|
||||||
|
{
|
||||||
|
Type = TaskTriggerInfo.TriggerDaily,
|
||||||
|
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = new InternalItemsQuery
|
||||||
|
{
|
||||||
|
MediaTypes = new[] { MediaType.Video },
|
||||||
|
SourceTypes = new[] { SourceType.Library },
|
||||||
|
IsVirtualItem = false,
|
||||||
|
IsFolder = false,
|
||||||
|
Recursive = true,
|
||||||
|
Limit = QueryPageLimit
|
||||||
|
};
|
||||||
|
|
||||||
|
var numberOfVideos = _libraryManager.GetCount(query);
|
||||||
|
|
||||||
|
var startIndex = 0;
|
||||||
|
var numComplete = 0;
|
||||||
|
|
||||||
|
while (startIndex < numberOfVideos)
|
||||||
|
{
|
||||||
|
query.StartIndex = startIndex;
|
||||||
|
var videos = _libraryManager.GetItemList(query).OfType<Video>();
|
||||||
|
|
||||||
|
foreach (var video in videos)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _trickplayManager.RefreshTrickplayDataAsync(video, false, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error creating trickplay files for {ItemName}", video.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
numComplete++;
|
||||||
|
progress.Report(100d * numComplete / numberOfVideos);
|
||||||
|
}
|
||||||
|
|
||||||
|
startIndex += QueryPageLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.Report(100);
|
||||||
|
}
|
||||||
|
}
|
121
MediaBrowser.Providers/Trickplay/TrickplayProvider.cs
Normal file
121
MediaBrowser.Providers/Trickplay/TrickplayProvider.cs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Controller.Trickplay;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Providers.Trickplay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Class TrickplayProvider. Provides images and metadata for trickplay
|
||||||
|
/// scrubbing previews.
|
||||||
|
/// </summary>
|
||||||
|
public class TrickplayProvider : ICustomMetadataProvider<Episode>,
|
||||||
|
ICustomMetadataProvider<MusicVideo>,
|
||||||
|
ICustomMetadataProvider<Movie>,
|
||||||
|
ICustomMetadataProvider<Trailer>,
|
||||||
|
ICustomMetadataProvider<Video>,
|
||||||
|
IHasItemChangeMonitor,
|
||||||
|
IHasOrder,
|
||||||
|
IForcedProvider
|
||||||
|
{
|
||||||
|
private readonly IServerConfigurationManager _config;
|
||||||
|
private readonly ITrickplayManager _trickplayManager;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TrickplayProvider"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">The configuration manager.</param>
|
||||||
|
/// <param name="trickplayManager">The trickplay manager.</param>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
public TrickplayProvider(
|
||||||
|
IServerConfigurationManager config,
|
||||||
|
ITrickplayManager trickplayManager,
|
||||||
|
ILibraryManager libraryManager)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_trickplayManager = trickplayManager;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "Trickplay Provider";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int Order => 100;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
|
||||||
|
{
|
||||||
|
if (item.IsFileProtocol)
|
||||||
|
{
|
||||||
|
var file = directoryService.GetFile(item.Path);
|
||||||
|
if (file is not null && item.DateModified != file.LastWriteTimeUtc)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ItemUpdateType> FetchAsync(Episode item, MetadataRefreshOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return FetchInternal(item, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ItemUpdateType> FetchAsync(MusicVideo item, MetadataRefreshOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return FetchInternal(item, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ItemUpdateType> FetchAsync(Movie item, MetadataRefreshOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return FetchInternal(item, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ItemUpdateType> FetchAsync(Trailer item, MetadataRefreshOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return FetchInternal(item, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ItemUpdateType> FetchAsync(Video item, MetadataRefreshOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return FetchInternal(item, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ItemUpdateType> FetchInternal(Video video, MetadataRefreshOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var libraryOptions = _libraryManager.GetLibraryOptions(video);
|
||||||
|
bool? enableDuringScan = libraryOptions?.ExtractTrickplayImagesDuringLibraryScan;
|
||||||
|
bool replace = options.ReplaceAllImages;
|
||||||
|
|
||||||
|
if (options.IsAutomated && !enableDuringScan.GetValueOrDefault(false))
|
||||||
|
{
|
||||||
|
return ItemUpdateType.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_config.Configuration.TrickplayOptions.ScanBehavior == TrickplayScanBehavior.Blocking)
|
||||||
|
{
|
||||||
|
await _trickplayManager.RefreshTrickplayDataAsync(video, replace, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_ = _trickplayManager.RefreshTrickplayDataAsync(video, replace, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The core doesn't need to trigger any save operations over this
|
||||||
|
return ItemUpdateType.None;
|
||||||
|
}
|
||||||
|
}
|
@ -2,14 +2,18 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography.Xml;
|
||||||
using BlurHashSharp.SkiaSharp;
|
using BlurHashSharp.SkiaSharp;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Extensions;
|
using MediaBrowser.Common.Extensions;
|
||||||
using MediaBrowser.Controller.Drawing;
|
using MediaBrowser.Controller.Drawing;
|
||||||
|
using MediaBrowser.Controller.LiveTv;
|
||||||
using MediaBrowser.Model.Drawing;
|
using MediaBrowser.Model.Drawing;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SkiaSharp;
|
using SkiaSharp;
|
||||||
|
using static System.Net.Mime.MediaTypeNames;
|
||||||
using SKSvg = SkiaSharp.Extended.Svg.SKSvg;
|
using SKSvg = SkiaSharp.Extended.Svg.SKSvg;
|
||||||
|
|
||||||
namespace Jellyfin.Drawing.Skia;
|
namespace Jellyfin.Drawing.Skia;
|
||||||
@ -515,6 +519,81 @@ public class SkiaEncoder : IImageEncoder
|
|||||||
splashBuilder.GenerateSplash(posters, backdrops, outputPath);
|
splashBuilder.GenerateSplash(posters, backdrops, outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight)
|
||||||
|
{
|
||||||
|
var paths = options.InputPaths;
|
||||||
|
var tileWidth = options.Width;
|
||||||
|
var tileHeight = options.Height;
|
||||||
|
|
||||||
|
if (paths.Count < 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("InputPaths cannot be empty.");
|
||||||
|
}
|
||||||
|
else if (paths.Count > tileWidth * tileHeight)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"InputPaths contains more images than would fit on {tileWidth}x{tileHeight} grid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no height provided, use height of first image.
|
||||||
|
if (!imgHeight.HasValue)
|
||||||
|
{
|
||||||
|
using var firstImg = Decode(paths[0], false, null, out _);
|
||||||
|
|
||||||
|
if (firstImg is null)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Could not decode image data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstImg.Width != imgWidth)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Image width does not match provided width.");
|
||||||
|
}
|
||||||
|
|
||||||
|
imgHeight = firstImg.Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make horizontal strips using every provided image.
|
||||||
|
using var tileGrid = new SKBitmap(imgWidth * tileWidth, imgHeight.Value * tileHeight);
|
||||||
|
using var canvas = new SKCanvas(tileGrid);
|
||||||
|
|
||||||
|
var imgIndex = 0;
|
||||||
|
for (var y = 0; y < tileHeight; y++)
|
||||||
|
{
|
||||||
|
for (var x = 0; x < tileWidth; x++)
|
||||||
|
{
|
||||||
|
if (imgIndex >= paths.Count)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var img = Decode(paths[imgIndex++], false, null, out _);
|
||||||
|
|
||||||
|
if (img is null)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Could not decode image data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (img.Width != imgWidth)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Image width does not match provided width.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (img.Height != imgHeight)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Image height does not match first image height.");
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.DrawBitmap(img, x * imgWidth, y * imgHeight.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var outputStream = new SKFileWStream(options.OutputPath);
|
||||||
|
tileGrid.Encode(outputStream, SKEncodedImageFormat.Jpeg, quality);
|
||||||
|
|
||||||
|
return imgHeight.Value;
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
|
private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
@ -49,6 +49,12 @@ public class NullImageEncoder : IImageEncoder
|
|||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string GetImageBlurHash(int xComp, int yComp, string path)
|
public string GetImageBlurHash(int xComp, int yComp, string path)
|
||||||
{
|
{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user