| | | 1 | | using System; |
| | | 2 | | using System.Collections.Generic; |
| | | 3 | | using System.ComponentModel.DataAnnotations; |
| | | 4 | | using System.Diagnostics.CodeAnalysis; |
| | | 5 | | using System.Globalization; |
| | | 6 | | using System.IO; |
| | | 7 | | using System.Linq; |
| | | 8 | | using System.Net.Mime; |
| | | 9 | | using System.Security.Cryptography; |
| | | 10 | | using System.Text; |
| | | 11 | | using System.Threading; |
| | | 12 | | using System.Threading.Tasks; |
| | | 13 | | using Jellyfin.Api.Attributes; |
| | | 14 | | using Jellyfin.Api.Extensions; |
| | | 15 | | using Jellyfin.Api.Helpers; |
| | | 16 | | using Jellyfin.Api.Models.SubtitleDtos; |
| | | 17 | | using MediaBrowser.Common.Api; |
| | | 18 | | using MediaBrowser.Common.Configuration; |
| | | 19 | | using MediaBrowser.Controller.Configuration; |
| | | 20 | | using MediaBrowser.Controller.Entities; |
| | | 21 | | using MediaBrowser.Controller.Library; |
| | | 22 | | using MediaBrowser.Controller.MediaEncoding; |
| | | 23 | | using MediaBrowser.Controller.Providers; |
| | | 24 | | using MediaBrowser.Controller.Subtitles; |
| | | 25 | | using MediaBrowser.Model.Entities; |
| | | 26 | | using MediaBrowser.Model.IO; |
| | | 27 | | using MediaBrowser.Model.Net; |
| | | 28 | | using MediaBrowser.Model.Providers; |
| | | 29 | | using MediaBrowser.Model.Subtitles; |
| | | 30 | | using Microsoft.AspNetCore.Authorization; |
| | | 31 | | using Microsoft.AspNetCore.Http; |
| | | 32 | | using Microsoft.AspNetCore.Mvc; |
| | | 33 | | using Microsoft.Extensions.Logging; |
| | | 34 | | |
| | | 35 | | namespace Jellyfin.Api.Controllers; |
| | | 36 | | |
| | | 37 | | /// <summary> |
| | | 38 | | /// Subtitle controller. |
| | | 39 | | /// </summary> |
| | | 40 | | [Route("")] |
| | | 41 | | public class SubtitleController : BaseJellyfinApiController |
| | | 42 | | { |
| | | 43 | | private readonly IServerConfigurationManager _serverConfigurationManager; |
| | | 44 | | private readonly ILibraryManager _libraryManager; |
| | | 45 | | private readonly ISubtitleManager _subtitleManager; |
| | | 46 | | private readonly ISubtitleEncoder _subtitleEncoder; |
| | | 47 | | private readonly IMediaSourceManager _mediaSourceManager; |
| | | 48 | | private readonly IProviderManager _providerManager; |
| | | 49 | | private readonly IFileSystem _fileSystem; |
| | | 50 | | private readonly ILogger<SubtitleController> _logger; |
| | | 51 | | |
| | | 52 | | /// <summary> |
| | | 53 | | /// Initializes a new instance of the <see cref="SubtitleController"/> class. |
| | | 54 | | /// </summary> |
| | | 55 | | /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param |
| | | 56 | | /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> |
| | | 57 | | /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param> |
| | | 58 | | /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param> |
| | | 59 | | /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param> |
| | | 60 | | /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> |
| | | 61 | | /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> |
| | | 62 | | /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param> |
| | 0 | 63 | | public SubtitleController( |
| | 0 | 64 | | IServerConfigurationManager serverConfigurationManager, |
| | 0 | 65 | | ILibraryManager libraryManager, |
| | 0 | 66 | | ISubtitleManager subtitleManager, |
| | 0 | 67 | | ISubtitleEncoder subtitleEncoder, |
| | 0 | 68 | | IMediaSourceManager mediaSourceManager, |
| | 0 | 69 | | IProviderManager providerManager, |
| | 0 | 70 | | IFileSystem fileSystem, |
| | 0 | 71 | | ILogger<SubtitleController> logger) |
| | | 72 | | { |
| | 0 | 73 | | _serverConfigurationManager = serverConfigurationManager; |
| | 0 | 74 | | _libraryManager = libraryManager; |
| | 0 | 75 | | _subtitleManager = subtitleManager; |
| | 0 | 76 | | _subtitleEncoder = subtitleEncoder; |
| | 0 | 77 | | _mediaSourceManager = mediaSourceManager; |
| | 0 | 78 | | _providerManager = providerManager; |
| | 0 | 79 | | _fileSystem = fileSystem; |
| | 0 | 80 | | _logger = logger; |
| | 0 | 81 | | } |
| | | 82 | | |
| | | 83 | | /// <summary> |
| | | 84 | | /// Deletes an external subtitle file. |
| | | 85 | | /// </summary> |
| | | 86 | | /// <param name="itemId">The item id.</param> |
| | | 87 | | /// <param name="index">The index of the subtitle file.</param> |
| | | 88 | | /// <response code="204">Subtitle deleted.</response> |
| | | 89 | | /// <response code="404">Item not found.</response> |
| | | 90 | | /// <returns>A <see cref="NoContentResult"/>.</returns> |
| | | 91 | | [HttpDelete("Videos/{itemId}/Subtitles/{index}")] |
| | | 92 | | [Authorize(Policy = Policies.RequiresElevation)] |
| | | 93 | | [ProducesResponseType(StatusCodes.Status204NoContent)] |
| | | 94 | | [ProducesResponseType(StatusCodes.Status404NotFound)] |
| | | 95 | | public async Task<ActionResult> DeleteSubtitle( |
| | | 96 | | [FromRoute, Required] Guid itemId, |
| | | 97 | | [FromRoute, Required] int index) |
| | | 98 | | { |
| | | 99 | | var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); |
| | | 100 | | if (item is null) |
| | | 101 | | { |
| | | 102 | | return NotFound(); |
| | | 103 | | } |
| | | 104 | | |
| | | 105 | | await _subtitleManager.DeleteSubtitles(item, index).ConfigureAwait(false); |
| | | 106 | | return NoContent(); |
| | | 107 | | } |
| | | 108 | | |
| | | 109 | | /// <summary> |
| | | 110 | | /// Search remote subtitles. |
| | | 111 | | /// </summary> |
| | | 112 | | /// <param name="itemId">The item id.</param> |
| | | 113 | | /// <param name="language">The language of the subtitles.</param> |
| | | 114 | | /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param> |
| | | 115 | | /// <response code="200">Subtitles retrieved.</response> |
| | | 116 | | /// <response code="404">Item not found.</response> |
| | | 117 | | /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns> |
| | | 118 | | [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")] |
| | | 119 | | [Authorize(Policy = Policies.SubtitleManagement)] |
| | | 120 | | [ProducesResponseType(StatusCodes.Status200OK)] |
| | | 121 | | [ProducesResponseType(StatusCodes.Status404NotFound)] |
| | | 122 | | public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles( |
| | | 123 | | [FromRoute, Required] Guid itemId, |
| | | 124 | | [FromRoute, Required] string language, |
| | | 125 | | [FromQuery] bool? isPerfectMatch) |
| | | 126 | | { |
| | | 127 | | var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); |
| | | 128 | | if (item is null) |
| | | 129 | | { |
| | | 130 | | return NotFound(); |
| | | 131 | | } |
| | | 132 | | |
| | | 133 | | return await _subtitleManager.SearchSubtitles(item, language, isPerfectMatch, false, CancellationToken.None).Con |
| | | 134 | | } |
| | | 135 | | |
| | | 136 | | /// <summary> |
| | | 137 | | /// Downloads a remote subtitle. |
| | | 138 | | /// </summary> |
| | | 139 | | /// <param name="itemId">The item id.</param> |
| | | 140 | | /// <param name="subtitleId">The subtitle id.</param> |
| | | 141 | | /// <response code="204">Subtitle downloaded.</response> |
| | | 142 | | /// <response code="404">Item not found.</response> |
| | | 143 | | /// <returns>A <see cref="NoContentResult"/>.</returns> |
| | | 144 | | [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] |
| | | 145 | | [Authorize(Policy = Policies.SubtitleManagement)] |
| | | 146 | | [ProducesResponseType(StatusCodes.Status204NoContent)] |
| | | 147 | | [ProducesResponseType(StatusCodes.Status404NotFound)] |
| | | 148 | | public async Task<ActionResult> DownloadRemoteSubtitles( |
| | | 149 | | [FromRoute, Required] Guid itemId, |
| | | 150 | | [FromRoute, Required] string subtitleId) |
| | | 151 | | { |
| | | 152 | | var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); |
| | | 153 | | if (item is null) |
| | | 154 | | { |
| | | 155 | | return NotFound(); |
| | | 156 | | } |
| | | 157 | | |
| | | 158 | | try |
| | | 159 | | { |
| | | 160 | | await _subtitleManager.DownloadSubtitles(item, subtitleId, CancellationToken.None) |
| | | 161 | | .ConfigureAwait(false); |
| | | 162 | | |
| | | 163 | | _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), Refres |
| | | 164 | | } |
| | | 165 | | catch (Exception ex) |
| | | 166 | | { |
| | | 167 | | _logger.LogError(ex, "Error downloading subtitles"); |
| | | 168 | | } |
| | | 169 | | |
| | | 170 | | return NoContent(); |
| | | 171 | | } |
| | | 172 | | |
| | | 173 | | /// <summary> |
| | | 174 | | /// Gets the remote subtitles. |
| | | 175 | | /// </summary> |
| | | 176 | | /// <param name="subtitleId">The item id.</param> |
| | | 177 | | /// <response code="200">File returned.</response> |
| | | 178 | | /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns> |
| | | 179 | | [HttpGet("Providers/Subtitles/Subtitles/{subtitleId}")] |
| | | 180 | | [Authorize(Policy = Policies.SubtitleManagement)] |
| | | 181 | | [ProducesResponseType(StatusCodes.Status200OK)] |
| | | 182 | | [Produces(MediaTypeNames.Application.Octet)] |
| | | 183 | | [ProducesFile("text/*")] |
| | | 184 | | public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string subtitleId) |
| | | 185 | | { |
| | | 186 | | var result = await _subtitleManager.GetRemoteSubtitles(subtitleId, CancellationToken.None).ConfigureAwait(false) |
| | | 187 | | |
| | | 188 | | return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); |
| | | 189 | | } |
| | | 190 | | |
| | | 191 | | /// <summary> |
| | | 192 | | /// Gets subtitles in a specified format. |
| | | 193 | | /// </summary> |
| | | 194 | | /// <param name="routeItemId">The (route) item id.</param> |
| | | 195 | | /// <param name="routeMediaSourceId">The (route) media source id.</param> |
| | | 196 | | /// <param name="routeIndex">The (route) subtitle stream index.</param> |
| | | 197 | | /// <param name="routeFormat">The (route) format of the returned subtitle.</param> |
| | | 198 | | /// <param name="itemId">The item id.</param> |
| | | 199 | | /// <param name="mediaSourceId">The media source id.</param> |
| | | 200 | | /// <param name="index">The subtitle stream index.</param> |
| | | 201 | | /// <param name="format">The format of the returned subtitle.</param> |
| | | 202 | | /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> |
| | | 203 | | /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> |
| | | 204 | | /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> |
| | | 205 | | /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> |
| | | 206 | | /// <response code="200">File returned.</response> |
| | | 207 | | /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> |
| | | 208 | | [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")] |
| | | 209 | | [ProducesResponseType(StatusCodes.Status200OK)] |
| | | 210 | | [ProducesFile("text/*")] |
| | | 211 | | public async Task<ActionResult> GetSubtitle( |
| | | 212 | | [FromRoute, Required] Guid routeItemId, |
| | | 213 | | [FromRoute, Required] string routeMediaSourceId, |
| | | 214 | | [FromRoute, Required] int routeIndex, |
| | | 215 | | [FromRoute, Required] string routeFormat, |
| | | 216 | | [FromQuery, ParameterObsolete] Guid? itemId, |
| | | 217 | | [FromQuery, ParameterObsolete] string? mediaSourceId, |
| | | 218 | | [FromQuery, ParameterObsolete] int? index, |
| | | 219 | | [FromQuery, ParameterObsolete] string? format, |
| | | 220 | | [FromQuery] long? endPositionTicks, |
| | | 221 | | [FromQuery] bool copyTimestamps = false, |
| | | 222 | | [FromQuery] bool addVttTimeMap = false, |
| | | 223 | | [FromQuery] long startPositionTicks = 0) |
| | | 224 | | { |
| | | 225 | | // Set parameters to route value if not provided via query. |
| | | 226 | | itemId ??= routeItemId; |
| | | 227 | | mediaSourceId ??= routeMediaSourceId; |
| | | 228 | | index ??= routeIndex; |
| | | 229 | | format ??= routeFormat; |
| | | 230 | | |
| | | 231 | | if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) |
| | | 232 | | { |
| | | 233 | | format = "json"; |
| | | 234 | | } |
| | | 235 | | |
| | | 236 | | if (string.IsNullOrEmpty(format)) |
| | | 237 | | { |
| | | 238 | | var item = _libraryManager.GetItemById<Video>(itemId.Value); |
| | | 239 | | |
| | | 240 | | var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); |
| | | 241 | | var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) |
| | | 242 | | .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); |
| | | 243 | | |
| | | 244 | | var subtitleStream = mediaSource.MediaStreams |
| | | 245 | | .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); |
| | | 246 | | |
| | | 247 | | return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path)); |
| | | 248 | | } |
| | | 249 | | |
| | | 250 | | if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) |
| | | 251 | | { |
| | | 252 | | Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, |
| | | 253 | | await using (stream.ConfigureAwait(false)) |
| | | 254 | | { |
| | | 255 | | using var reader = new StreamReader(stream); |
| | | 256 | | |
| | | 257 | | var text = await reader.ReadToEndAsync().ConfigureAwait(false); |
| | | 258 | | |
| | | 259 | | text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparis |
| | | 260 | | |
| | | 261 | | return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format)); |
| | | 262 | | } |
| | | 263 | | } |
| | | 264 | | |
| | | 265 | | return File( |
| | | 266 | | await EncodeSubtitles( |
| | | 267 | | itemId.Value, |
| | | 268 | | mediaSourceId, |
| | | 269 | | index.Value, |
| | | 270 | | format, |
| | | 271 | | startPositionTicks, |
| | | 272 | | endPositionTicks, |
| | | 273 | | copyTimestamps).ConfigureAwait(false), |
| | | 274 | | MimeTypes.GetMimeType("file." + format)); |
| | | 275 | | } |
| | | 276 | | |
| | | 277 | | /// <summary> |
| | | 278 | | /// Gets subtitles in a specified format. |
| | | 279 | | /// </summary> |
| | | 280 | | /// <param name="routeItemId">The (route) item id.</param> |
| | | 281 | | /// <param name="routeMediaSourceId">The (route) media source id.</param> |
| | | 282 | | /// <param name="routeIndex">The (route) subtitle stream index.</param> |
| | | 283 | | /// <param name="routeStartPositionTicks">The (route) start position of the subtitle in ticks.</param> |
| | | 284 | | /// <param name="routeFormat">The (route) format of the returned subtitle.</param> |
| | | 285 | | /// <param name="itemId">The item id.</param> |
| | | 286 | | /// <param name="mediaSourceId">The media source id.</param> |
| | | 287 | | /// <param name="index">The subtitle stream index.</param> |
| | | 288 | | /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> |
| | | 289 | | /// <param name="format">The format of the returned subtitle.</param> |
| | | 290 | | /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> |
| | | 291 | | /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> |
| | | 292 | | /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> |
| | | 293 | | /// <response code="200">File returned.</response> |
| | | 294 | | /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> |
| | | 295 | | [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFo |
| | | 296 | | [ProducesResponseType(StatusCodes.Status200OK)] |
| | | 297 | | [ProducesFile("text/*")] |
| | | 298 | | public Task<ActionResult> GetSubtitleWithTicks( |
| | | 299 | | [FromRoute, Required] Guid routeItemId, |
| | | 300 | | [FromRoute, Required] string routeMediaSourceId, |
| | | 301 | | [FromRoute, Required] int routeIndex, |
| | | 302 | | [FromRoute, Required] long routeStartPositionTicks, |
| | | 303 | | [FromRoute, Required] string routeFormat, |
| | | 304 | | [FromQuery, ParameterObsolete] Guid? itemId, |
| | | 305 | | [FromQuery, ParameterObsolete] string? mediaSourceId, |
| | | 306 | | [FromQuery, ParameterObsolete] int? index, |
| | | 307 | | [FromQuery, ParameterObsolete] long? startPositionTicks, |
| | | 308 | | [FromQuery, ParameterObsolete] string? format, |
| | | 309 | | [FromQuery] long? endPositionTicks, |
| | | 310 | | [FromQuery] bool copyTimestamps = false, |
| | | 311 | | [FromQuery] bool addVttTimeMap = false) |
| | | 312 | | { |
| | 0 | 313 | | return GetSubtitle( |
| | 0 | 314 | | routeItemId, |
| | 0 | 315 | | routeMediaSourceId, |
| | 0 | 316 | | routeIndex, |
| | 0 | 317 | | routeFormat, |
| | 0 | 318 | | itemId, |
| | 0 | 319 | | mediaSourceId, |
| | 0 | 320 | | index, |
| | 0 | 321 | | format, |
| | 0 | 322 | | endPositionTicks, |
| | 0 | 323 | | copyTimestamps, |
| | 0 | 324 | | addVttTimeMap, |
| | 0 | 325 | | startPositionTicks ?? routeStartPositionTicks); |
| | | 326 | | } |
| | | 327 | | |
| | | 328 | | /// <summary> |
| | | 329 | | /// Gets an HLS subtitle playlist. |
| | | 330 | | /// </summary> |
| | | 331 | | /// <param name="itemId">The item id.</param> |
| | | 332 | | /// <param name="index">The subtitle stream index.</param> |
| | | 333 | | /// <param name="mediaSourceId">The media source id.</param> |
| | | 334 | | /// <param name="segmentLength">The subtitle segment length.</param> |
| | | 335 | | /// <response code="200">Subtitle playlist retrieved.</response> |
| | | 336 | | /// <response code="404">Item not found.</response> |
| | | 337 | | /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> |
| | | 338 | | [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] |
| | | 339 | | [Authorize] |
| | | 340 | | [ProducesResponseType(StatusCodes.Status200OK)] |
| | | 341 | | [ProducesResponseType(StatusCodes.Status404NotFound)] |
| | | 342 | | [ProducesPlaylistFile] |
| | | 343 | | [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imp |
| | | 344 | | public async Task<ActionResult> GetSubtitlePlaylist( |
| | | 345 | | [FromRoute, Required] Guid itemId, |
| | | 346 | | [FromRoute, Required] int index, |
| | | 347 | | [FromRoute, Required] string mediaSourceId, |
| | | 348 | | [FromQuery, Required] int segmentLength) |
| | | 349 | | { |
| | | 350 | | var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); |
| | | 351 | | if (item is null) |
| | | 352 | | { |
| | | 353 | | return NotFound(); |
| | | 354 | | } |
| | | 355 | | |
| | | 356 | | var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.N |
| | | 357 | | |
| | | 358 | | var runtime = mediaSource.RunTimeTicks ?? -1; |
| | | 359 | | |
| | | 360 | | if (runtime <= 0) |
| | | 361 | | { |
| | | 362 | | throw new ArgumentException("HLS Subtitles are not supported for this media."); |
| | | 363 | | } |
| | | 364 | | |
| | | 365 | | var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks; |
| | | 366 | | if (segmentLengthTicks <= 0) |
| | | 367 | | { |
| | | 368 | | throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger |
| | | 369 | | } |
| | | 370 | | |
| | | 371 | | var builder = new StringBuilder(); |
| | | 372 | | builder.AppendLine("#EXTM3U") |
| | | 373 | | .Append("#EXT-X-TARGETDURATION:") |
| | | 374 | | .Append(segmentLength) |
| | | 375 | | .AppendLine() |
| | | 376 | | .AppendLine("#EXT-X-VERSION:3") |
| | | 377 | | .AppendLine("#EXT-X-MEDIA-SEQUENCE:0") |
| | | 378 | | .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); |
| | | 379 | | |
| | | 380 | | long positionTicks = 0; |
| | | 381 | | |
| | | 382 | | var accessToken = User.GetToken(); |
| | | 383 | | |
| | | 384 | | while (positionTicks < runtime) |
| | | 385 | | { |
| | | 386 | | var remaining = runtime - positionTicks; |
| | | 387 | | var lengthTicks = Math.Min(remaining, segmentLengthTicks); |
| | | 388 | | |
| | | 389 | | builder.Append("#EXTINF:") |
| | | 390 | | .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds) |
| | | 391 | | .Append(',') |
| | | 392 | | .AppendLine(); |
| | | 393 | | |
| | | 394 | | var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); |
| | | 395 | | |
| | | 396 | | var url = string.Format( |
| | | 397 | | CultureInfo.InvariantCulture, |
| | | 398 | | "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&ApiKey={2 |
| | | 399 | | positionTicks.ToString(CultureInfo.InvariantCulture), |
| | | 400 | | endPositionTicks.ToString(CultureInfo.InvariantCulture), |
| | | 401 | | accessToken); |
| | | 402 | | |
| | | 403 | | builder.AppendLine(url); |
| | | 404 | | |
| | | 405 | | positionTicks += segmentLengthTicks; |
| | | 406 | | } |
| | | 407 | | |
| | | 408 | | builder.AppendLine("#EXT-X-ENDLIST"); |
| | | 409 | | return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); |
| | | 410 | | } |
| | | 411 | | |
| | | 412 | | /// <summary> |
| | | 413 | | /// Upload an external subtitle file. |
| | | 414 | | /// </summary> |
| | | 415 | | /// <param name="itemId">The item the subtitle belongs to.</param> |
| | | 416 | | /// <param name="body">The request body.</param> |
| | | 417 | | /// <response code="204">Subtitle uploaded.</response> |
| | | 418 | | /// <response code="404">Item not found.</response> |
| | | 419 | | /// <returns>A <see cref="NoContentResult"/>.</returns> |
| | | 420 | | [HttpPost("Videos/{itemId}/Subtitles")] |
| | | 421 | | [Authorize(Policy = Policies.SubtitleManagement)] |
| | | 422 | | [ProducesResponseType(StatusCodes.Status204NoContent)] |
| | | 423 | | [ProducesResponseType(StatusCodes.Status404NotFound)] |
| | | 424 | | public async Task<ActionResult> UploadSubtitle( |
| | | 425 | | [FromRoute, Required] Guid itemId, |
| | | 426 | | [FromBody, Required] UploadSubtitleDto body) |
| | | 427 | | { |
| | | 428 | | var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); |
| | | 429 | | if (item is null) |
| | | 430 | | { |
| | | 431 | | return NotFound(); |
| | | 432 | | } |
| | | 433 | | |
| | | 434 | | var bytes = Encoding.UTF8.GetBytes(body.Data); |
| | | 435 | | var memoryStream = new MemoryStream(bytes, 0, bytes.Length, false, true); |
| | | 436 | | await using (memoryStream.ConfigureAwait(false)) |
| | | 437 | | { |
| | | 438 | | using var transform = new FromBase64Transform(); |
| | | 439 | | var stream = new CryptoStream(memoryStream, transform, CryptoStreamMode.Read); |
| | | 440 | | await using (stream.ConfigureAwait(false)) |
| | | 441 | | { |
| | | 442 | | await _subtitleManager.UploadSubtitle( |
| | | 443 | | item, |
| | | 444 | | new SubtitleResponse |
| | | 445 | | { |
| | | 446 | | Format = body.Format, |
| | | 447 | | Language = body.Language, |
| | | 448 | | IsForced = body.IsForced, |
| | | 449 | | IsHearingImpaired = body.IsHearingImpaired, |
| | | 450 | | Stream = stream |
| | | 451 | | }).ConfigureAwait(false); |
| | | 452 | | _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), Re |
| | | 453 | | |
| | | 454 | | return NoContent(); |
| | | 455 | | } |
| | | 456 | | } |
| | | 457 | | } |
| | | 458 | | |
| | | 459 | | /// <summary> |
| | | 460 | | /// Encodes a subtitle in the specified format. |
| | | 461 | | /// </summary> |
| | | 462 | | /// <param name="id">The media id.</param> |
| | | 463 | | /// <param name="mediaSourceId">The source media id.</param> |
| | | 464 | | /// <param name="index">The subtitle index.</param> |
| | | 465 | | /// <param name="format">The format to convert to.</param> |
| | | 466 | | /// <param name="startPositionTicks">The start position in ticks.</param> |
| | | 467 | | /// <param name="endPositionTicks">The end position in ticks.</param> |
| | | 468 | | /// <param name="copyTimestamps">Whether to copy the timestamps.</param> |
| | | 469 | | /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns> |
| | | 470 | | private Task<Stream> EncodeSubtitles( |
| | | 471 | | Guid id, |
| | | 472 | | string? mediaSourceId, |
| | | 473 | | int index, |
| | | 474 | | string format, |
| | | 475 | | long startPositionTicks, |
| | | 476 | | long? endPositionTicks, |
| | | 477 | | bool copyTimestamps) |
| | | 478 | | { |
| | 0 | 479 | | var item = _libraryManager.GetItemById<BaseItem>(id); |
| | | 480 | | |
| | 0 | 481 | | return _subtitleEncoder.GetSubtitles( |
| | 0 | 482 | | item, |
| | 0 | 483 | | mediaSourceId, |
| | 0 | 484 | | index, |
| | 0 | 485 | | format, |
| | 0 | 486 | | startPositionTicks, |
| | 0 | 487 | | endPositionTicks ?? 0, |
| | 0 | 488 | | copyTimestamps, |
| | 0 | 489 | | CancellationToken.None); |
| | | 490 | | } |
| | | 491 | | |
| | | 492 | | /// <summary> |
| | | 493 | | /// Gets a list of available fallback font files. |
| | | 494 | | /// </summary> |
| | | 495 | | /// <response code="200">Information retrieved.</response> |
| | | 496 | | /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns> |
| | | 497 | | [HttpGet("FallbackFont/Fonts")] |
| | | 498 | | [Authorize] |
| | | 499 | | [ProducesResponseType(StatusCodes.Status200OK)] |
| | | 500 | | public IEnumerable<FontFile> GetFallbackFontList() |
| | | 501 | | { |
| | | 502 | | var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); |
| | | 503 | | var fallbackFontPath = encodingOptions.FallbackFontPath; |
| | | 504 | | |
| | | 505 | | if (!string.IsNullOrEmpty(fallbackFontPath)) |
| | | 506 | | { |
| | | 507 | | var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false |
| | | 508 | | var fontFiles = files |
| | | 509 | | .Select(i => new FontFile |
| | | 510 | | { |
| | | 511 | | Name = i.Name, |
| | | 512 | | Size = i.Length, |
| | | 513 | | DateCreated = _fileSystem.GetCreationTimeUtc(i), |
| | | 514 | | DateModified = _fileSystem.GetLastWriteTimeUtc(i) |
| | | 515 | | }) |
| | | 516 | | .OrderBy(i => i.Size) |
| | | 517 | | .ThenBy(i => i.Name) |
| | | 518 | | .ThenByDescending(i => i.DateModified) |
| | | 519 | | .ThenByDescending(i => i.DateCreated); |
| | | 520 | | // max total size 20M |
| | | 521 | | const int MaxSize = 20971520; |
| | | 522 | | var sizeCounter = 0L; |
| | | 523 | | foreach (var fontFile in fontFiles) |
| | | 524 | | { |
| | | 525 | | sizeCounter += fontFile.Size; |
| | | 526 | | if (sizeCounter >= MaxSize) |
| | | 527 | | { |
| | | 528 | | _logger.LogWarning("Some fonts will not be sent due to size limitations"); |
| | | 529 | | yield break; |
| | | 530 | | } |
| | | 531 | | |
| | | 532 | | yield return fontFile; |
| | | 533 | | } |
| | | 534 | | } |
| | | 535 | | else |
| | | 536 | | { |
| | | 537 | | _logger.LogWarning("The path of fallback font folder has not been set"); |
| | | 538 | | encodingOptions.EnableFallbackFont = false; |
| | | 539 | | } |
| | | 540 | | } |
| | | 541 | | |
| | | 542 | | /// <summary> |
| | | 543 | | /// Gets a fallback font file. |
| | | 544 | | /// </summary> |
| | | 545 | | /// <param name="name">The name of the fallback font file to get.</param> |
| | | 546 | | /// <response code="200">Fallback font file retrieved.</response> |
| | | 547 | | /// <returns>The fallback font file.</returns> |
| | | 548 | | [HttpGet("FallbackFont/Fonts/{name}")] |
| | | 549 | | [Authorize] |
| | | 550 | | [ProducesResponseType(StatusCodes.Status200OK)] |
| | | 551 | | [ProducesFile("font/*")] |
| | | 552 | | public ActionResult GetFallbackFont([FromRoute, Required] string name) |
| | | 553 | | { |
| | 0 | 554 | | var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); |
| | 0 | 555 | | var fallbackFontPath = encodingOptions.FallbackFontPath; |
| | | 556 | | |
| | 0 | 557 | | if (!string.IsNullOrEmpty(fallbackFontPath)) |
| | | 558 | | { |
| | 0 | 559 | | var fontFile = _fileSystem.GetFiles(fallbackFontPath) |
| | 0 | 560 | | .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); |
| | 0 | 561 | | var fileSize = fontFile?.Length; |
| | | 562 | | |
| | 0 | 563 | | if (fontFile is not null && fileSize is not null && fileSize > 0) |
| | | 564 | | { |
| | 0 | 565 | | _logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize); |
| | 0 | 566 | | return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); |
| | | 567 | | } |
| | | 568 | | |
| | 0 | 569 | | _logger.LogWarning("The selected font is null or empty"); |
| | | 570 | | } |
| | | 571 | | else |
| | | 572 | | { |
| | 0 | 573 | | _logger.LogWarning("The path of fallback font folder has not been set"); |
| | 0 | 574 | | encodingOptions.EnableFallbackFont = false; |
| | | 575 | | } |
| | | 576 | | |
| | | 577 | | // returning HTTP 204 will break the SubtitlesOctopus |
| | 0 | 578 | | return Ok(); |
| | | 579 | | } |
| | | 580 | | } |