// Copyright (C) 2024 Intro-Skipper contributors // SPDX-License-Identifier: GPL-3.0-only. using System; using System.Collections.Generic; using System.Linq; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using IntroSkipper.Configuration; using IntroSkipper.Data; using IntroSkipper.Db; using IntroSkipper.Manager; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace IntroSkipper.Controllers; /// /// Skip intro controller. /// [Authorize] [ApiController] [Produces(MediaTypeNames.Application.Json)] public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateManager) : ControllerBase { private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager; /// /// 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 intros = GetIntros(id); if (!intros.TryGetValue(mode, out var intro)) { 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. /// Cancellation Token. /// New timestamps saved. /// Given ID is not an Episode. /// No content. [Authorize(Policy = Policies.RequiresElevation)] [HttpPost("Episode/{Id}/Timestamps")] public async Task UpdateTimestampsAsync([FromRoute] Guid id, [FromBody] TimeStamps timestamps, CancellationToken cancellationToken = default) { // only update existing episodes var rawItem = Plugin.Instance!.GetItem(id); if (rawItem is not Episode and not Movie) { return NotFound(); } if (timestamps == null) { return NoContent(); } var segmentTypes = new[] { (AnalysisMode.Introduction, timestamps.Introduction), (AnalysisMode.Credits, timestamps.Credits), (AnalysisMode.Recap, timestamps.Recap), (AnalysisMode.Preview, timestamps.Preview) }; foreach (var (mode, segment) in segmentTypes) { if (segment.Valid) { await Plugin.Instance!.UpdateTimestampAsync(segment, mode).ConfigureAwait(false); } } if (Plugin.Instance.Configuration.UpdateMediaSegments) { var episode = Plugin.Instance!.QueuedMediaItems[rawItem is Episode e ? e.SeasonId : rawItem.Id] .FirstOrDefault(q => q.EpisodeId == rawItem.Id); if (episode is not null) { await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync([episode], cancellationToken).ConfigureAwait(false); } } 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 is not Episode and not Movie) { return NotFound(); } var times = new TimeStamps(); var segments = Plugin.Instance!.GetTimestamps(id); if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment)) { times.Introduction = introSegment; } if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment)) { times.Credits = creditSegment; } if (segments.TryGetValue(AnalysisMode.Recap, out var recapSegment)) { times.Recap = recapSegment; } if (segments.TryGetValue(AnalysisMode.Preview, out var previewSegment)) { times.Preview = previewSegment; } 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 = GetIntros(id); var result = new Dictionary(); if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment)) { result[AnalysisMode.Introduction] = introSegment; } if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment)) { result[AnalysisMode.Credits] = creditSegment; } return result; } /// Lookup and return the skippable timestamps for the provided item. /// Unique identifier of this episode. /// Intro object if the provided item has an intro, null otherwise. internal static Dictionary GetIntros(Guid id) { var timestamps = Plugin.Instance!.GetTimestamps(id); var intros = new Dictionary(); var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds; var config = Plugin.Instance.Configuration; foreach (var (mode, timestamp) in timestamps) { if (!timestamp.Valid) { continue; } // Create new Intro to avoid mutating the original stored in dictionary var segment = new Intro(timestamp); // Calculate intro end time segment.IntroEnd = runTime > 0 && runTime < segment.IntroEnd + 1 ? runTime : segment.IntroEnd - config.RemainingSecondsOfIntro; // Set skip button prompt visibility times const double MIN_REMAINING_TIME = 3.0; // Minimum seconds before end to hide prompt if (config.PersistSkipButton) { segment.ShowSkipPromptAt = segment.IntroStart; segment.HideSkipPromptAt = segment.IntroEnd - MIN_REMAINING_TIME; } else { segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment); segment.HideSkipPromptAt = Math.Min( segment.IntroStart + config.HidePromptAdjustment, segment.IntroEnd - MIN_REMAINING_TIME); } intros[mode] = segment; } return intros; } /// /// Erases all previously discovered introduction timestamps. /// /// Mode. /// Erase cache. /// Operation successful. /// No content. [Authorize(Policy = Policies.RequiresElevation)] [HttpPost("Intros/EraseTimestamps")] public async Task ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false) { using var db = new IntroSkipperDbContext(Plugin.Instance!.DbPath); var segments = await db.DbSegment .Where(s => s.Type == mode) .ToListAsync() .ConfigureAwait(false); db.DbSegment.RemoveRange(segments); await db.SaveChangesAsync().ConfigureAwait(false); if (eraseCache && mode is AnalysisMode.Introduction or AnalysisMode.Credits) { await Task.Run(() => FFmpegWrapper.DeleteCacheFiles(mode)).ConfigureAwait(false); } 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.SkipButtonEnabled, config.SkipButtonIntroText, config.SkipButtonEndCreditsText, config.AutoSkip, config.AutoSkipCredits, config.AutoSkipRecap, config.AutoSkipPreview, config.ClientList); } }