From 6864ebbf0a4b27e5790b678cb06b3d985539ddd3 Mon Sep 17 00:00:00 2001 From: da3dsoul Date: Tue, 21 Nov 2023 11:03:31 -0500 Subject: [PATCH] APIv3 support for stream scrobbling and External Subtitles --- .../API/v3/Controllers/FileController.cs | 72 ++++++++++++++++++- .../v3/Models/Shoko/ScrobblingFileResult.cs | 35 +++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 Shoko.Server/API/v3/Models/Shoko/ScrobblingFileResult.cs diff --git a/Shoko.Server/API/v3/Controllers/FileController.cs b/Shoko.Server/API/v3/Controllers/FileController.cs index dec4d4b76..ded6fd87e 100644 --- a/Shoko.Server/API/v3/Controllers/FileController.cs +++ b/Shoko.Server/API/v3/Controllers/FileController.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.IO; using System.Linq; using System.Text.RegularExpressions; +using Microsoft.ApplicationInsights.AspNetCore.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -12,6 +14,7 @@ using Shoko.Server.API.ModelBinders; using Shoko.Server.API.v3.Helpers; using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.API.v3.Models.Shoko; using Shoko.Server.Models; using Shoko.Server.Providers.TraktTV; using Shoko.Server.Commands; @@ -26,6 +29,7 @@ using MediaInfo = Shoko.Server.API.v3.Models.Shoko.MediaInfo; using DataSource = Shoko.Server.API.v3.Models.Common.DataSource; using Shoko.Server.Utilities; +using EpisodeType = Shoko.Models.Enums.EpisodeType; namespace Shoko.Server.API.v3.Controllers; @@ -472,15 +476,21 @@ public ActionResult RescanFileByAniDBFileID([FromRoute] int anidbFileID, [FromQu /// Returns a file stream for the specified file ID. /// /// Shoko ID + /// Can use this to select a specific place (if the name is different). This is mostly used as a hint for players + /// If this is enabled, then the file is marked as watched when the stream reaches the end. + /// This is not a good way to scrobble, but it allows for players without plugin support to have an option to scrobble. + /// The readahead buffer on the player would determine the required percentage to scrobble. /// A file stream for the specified file. [HttpGet("{fileID}/Stream")] - public ActionResult GetFileStream([FromRoute] int fileID) + [HttpGet("{fileID}/StreamDirectory/{filename}")] + public ActionResult GetFileStream([FromRoute] int fileID, [FromRoute] string filename = null, [FromQuery] bool streamPositionScrobbling = false) { var file = RepoFactory.VideoLocal.GetByID(fileID); if (file == null) return NotFound(FileNotFoundWithFileID); - var bestLocation = file.GetBestVideoLocalPlace(); + var bestLocation = file.Places.FirstOrDefault(a => a.FileName.Equals(filename)); + bestLocation ??= file.GetBestVideoLocalPlace(); var fileInfo = bestLocation.GetFile(); if (fileInfo == null) @@ -490,7 +500,63 @@ public ActionResult GetFileStream([FromRoute] int fileID) if (!provider.TryGetContentType(fileInfo.FullName, out var contentType)) contentType = "application/octet-stream"; - return PhysicalFile(fileInfo.FullName, contentType, enableRangeProcessing: true); + if (streamPositionScrobbling) + { + var scrobbleFile = new ScrobblingFileResult(file, User.JMMUserID, fileInfo.FullName, contentType) + { + FileDownloadName = filename ?? fileInfo.Name + }; + return scrobbleFile; + } + + var physicalFile = PhysicalFile(fileInfo.FullName, contentType); + physicalFile.FileDownloadName = filename ?? fileInfo.Name; + return physicalFile; + } + + /// + /// Returns the external subtitles for a file + /// + /// Shoko ID + /// A file stream for the specified file. + [HttpGet("{fileID}/StreamDirectory/")] + public ActionResult GetFileStreamDirectory([FromRoute] int fileID) + { + var file = RepoFactory.VideoLocal.GetByID(fileID); + if (file == null) + return NotFound(FileNotFoundWithFileID); + + var routeTemplate = Request.Scheme + "://" + Request.Host + "/api/v3/File/" + fileID + "/StreamDirectory/ExternalSub/"; + return new ObjectResult("" + string.Join(string.Empty, + file.Media.TextStreams.Where(a => a.External).Select(a => $"")) + "
"); + } + + /// + /// Gets an external subtitle file + /// + /// + /// + /// + [HttpGet("{fileID}/StreamDirectory/ExternalSub/{filename}")] + public ActionResult GetExternalSubtitle([FromRoute] int fileID, [FromRoute] string filename) + { + var file = RepoFactory.VideoLocal.GetByID(fileID); + if (file == null) + return NotFound(FileNotFoundWithFileID); + + + foreach (var place in file.Places) + { + var path = place.GetFile()?.Directory?.FullName; + if (path == null) continue; + path = Path.Combine(path, filename); + var subFile = new FileInfo(path); + if (!subFile.Exists) continue; + + return PhysicalFile(subFile.FullName, "application/octet-stream"); + } + + return NotFound(); } /// diff --git a/Shoko.Server/API/v3/Models/Shoko/ScrobblingFileResult.cs b/Shoko.Server/API/v3/Models/Shoko/ScrobblingFileResult.cs new file mode 100644 index 000000000..8a7de915a --- /dev/null +++ b/Shoko.Server/API/v3/Models/Shoko/ScrobblingFileResult.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using Shoko.Server.Models; + +namespace Shoko.Server.API.v3.Models.Shoko; + +public class ScrobblingFileResult : PhysicalFileResult +{ + private SVR_VideoLocal VideoLocal { get; set; } + private int UserID { get; set; } + public ScrobblingFileResult(SVR_VideoLocal videoLocal, int userID, string fileName, string contentType) : base(fileName, contentType) + { + VideoLocal = videoLocal; + UserID = userID; + EnableRangeProcessing = true; + } + + public ScrobblingFileResult(SVR_VideoLocal videoLocal, int userID, string fileName, MediaTypeHeaderValue contentType) : base(fileName, contentType) + { + VideoLocal = videoLocal; + UserID = userID; + EnableRangeProcessing = true; + } + + public override async Task ExecuteResultAsync(ActionContext context) + { + await base.ExecuteResultAsync(context); +#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + Task.Factory.StartNew(() => VideoLocal.ToggleWatchedStatus(true, UserID), new CancellationToken(), TaskCreationOptions.LongRunning, +#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + TaskScheduler.Default); + } +}