// Copyright (C) 2024 Intro-Skipper contributors // SPDX-License-Identifier: GPL-3.0-only. using System; using System.Collections.Generic; using System.Net.Mime; using IntroSkipper.Configuration; using IntroSkipper.Data; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace IntroSkipper.Controllers; /// /// Skip intro controller. /// [Authorize] [ApiController] [Produces(MediaTypeNames.Application.Json)] public class SkipIntroController : ControllerBase { /// /// Initializes a new instance of the class. /// public SkipIntroController() { } /// /// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format. /// /// ID of the episode. Required. /// Timestamps to return. Optional. Defaults to Introduction for backwards compatibility. /// Episode contains an intro. /// Failed to find an intro in the provided episode. /// Detected intro. [HttpGet("Episode/{id}/IntroTimestamps")] [HttpGet("Episode/{id}/IntroTimestamps/v1")] public ActionResult GetIntroTimestamps( [FromRoute] Guid id, [FromQuery] AnalysisMode mode = AnalysisMode.Introduction) { var intro = GetIntro(id, mode); if (intro is null || !intro.Valid) { return NotFound(); } return intro; } /// /// Updates the timestamps for the provided episode. /// /// Episode ID to update timestamps for. /// New timestamps Introduction/Credits start and end times. /// New timestamps saved. /// Given ID is not an Episode. /// No content. [Authorize(Policy = Policies.RequiresElevation)] [HttpPost("Episode/{Id}/Timestamps")] public ActionResult UpdateTimestamps([FromRoute] Guid id, [FromBody] TimeStamps timestamps) { // only update existing episodes var rawItem = Plugin.Instance!.GetItem(id); if (rawItem == null || rawItem is not Episode and not Movie) { return NotFound(); } if (timestamps?.Introduction.End > 0.0) { var tr = new TimeRange(timestamps.Introduction.Start, timestamps.Introduction.End); Plugin.Instance!.Intros[id] = new Segment(id, tr); } if (timestamps?.Credits.End > 0.0) { var cr = new TimeRange(timestamps.Credits.Start, timestamps.Credits.End); Plugin.Instance!.Credits[id] = new Segment(id, cr); } Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction); Plugin.Instance!.SaveTimestamps(AnalysisMode.Credits); return NoContent(); } /// /// Gets the timestamps for the provided episode. /// /// Episode ID. /// Sucess. /// Given ID is not an Episode. /// Episode Timestamps. [HttpGet("Episode/{Id}/Timestamps")] [ActionName("UpdateTimestamps")] public ActionResult GetTimestamps([FromRoute] Guid id) { // only get return content for episodes var rawItem = Plugin.Instance!.GetItem(id); if (rawItem == null || rawItem is not Episode and not Movie) { return NotFound(); } var times = new TimeStamps(); if (Plugin.Instance!.Intros.TryGetValue(id, out var introValue)) { times.Introduction = introValue; } if (Plugin.Instance!.Credits.TryGetValue(id, out var creditValue)) { times.Credits = creditValue; } return times; } /// /// Gets a dictionary of all skippable segments. /// /// Media ID. /// Skippable segments dictionary. /// Dictionary of skippable segments. [HttpGet("Episode/{id}/IntroSkipperSegments")] public ActionResult> GetSkippableSegments([FromRoute] Guid id) { var segments = new Dictionary(); if (GetIntro(id, AnalysisMode.Introduction) is Intro intro) { segments[AnalysisMode.Introduction] = intro; } if (GetIntro(id, AnalysisMode.Credits) is Intro credits) { segments[AnalysisMode.Credits] = credits; } return segments; } /// Lookup and return the skippable timestamps for the provided item. /// Unique identifier of this episode. /// Mode. /// Intro object if the provided item has an intro, null otherwise. private static Intro? GetIntro(Guid id, AnalysisMode mode) { try { var timestamp = Plugin.GetIntroByMode(id, mode); // Operate on a copy to avoid mutating the original Intro object stored in the dictionary. var segment = new Intro(timestamp); var config = Plugin.Instance!.Configuration; segment.IntroEnd = mode == AnalysisMode.Credits ? GetAdjustedIntroEnd(id, segment.IntroEnd, config) : segment.IntroEnd - config.RemainingSecondsOfIntro; if (config.PersistSkipButton) { segment.ShowSkipPromptAt = segment.IntroStart; segment.HideSkipPromptAt = segment.IntroEnd - 3; } else { segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment); segment.HideSkipPromptAt = Math.Min( segment.IntroStart + config.HidePromptAdjustment, segment.IntroEnd - 3); } return segment; } catch (KeyNotFoundException) { return null; } } private static double GetAdjustedIntroEnd(Guid id, double segmentEnd, PluginConfiguration config) { var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds; return runTime > 0 && runTime < segmentEnd + 1 ? runTime : segmentEnd - config.RemainingSecondsOfIntro; } /// /// Erases all previously discovered introduction timestamps. /// /// Mode. /// Erase cache. /// Operation successful. /// No content. [Authorize(Policy = Policies.RequiresElevation)] [HttpPost("Intros/EraseTimestamps")] public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false) { if (mode == AnalysisMode.Introduction) { Plugin.Instance!.Intros.Clear(); } else if (mode == AnalysisMode.Credits) { Plugin.Instance!.Credits.Clear(); } if (eraseCache) { FFmpegWrapper.DeleteCacheFiles(mode); } Plugin.Instance!.EpisodeStates.Clear(); Plugin.Instance!.SaveTimestamps(mode); return NoContent(); } /// /// Gets the user interface configuration. /// /// UserInterfaceConfiguration returned. /// UserInterfaceConfiguration. [HttpGet] [Route("Intros/UserInterfaceConfiguration")] public ActionResult GetUserInterfaceConfiguration() { var config = Plugin.Instance!.Configuration; return new UserInterfaceConfiguration( config.SkipButtonVisible, config.SkipButtonIntroText, config.SkipButtonEndCreditsText, config.AutoSkip, config.AutoSkipCredits, config.ClientList); } }