Recaps and Previews Support (#357)

* Recaps and Previews Support

* Add draft UI of preview / recap edit

* remove intro/credit tasks

* Update configPage.html

* rename task

* Reorganize settings by relation

* More standardized formatting

* Some additional formatting

* fix a typo

* Update configPage.html

* Allow missing recap / prview data

* More risk to corrupt than benefit

* Update TimeStamps.cs

* Update PluginConfiguration.cs

* Update configPage.html

* Update PluginConfiguration.cs

* Add chapter regex to settings

* Move all UI into UI section

* Move ending seconds with similar

* Add default

* fixes

* Update SkipIntroController.cs

* Autoskip all segments

* Check if adjacent segment

* Update AutoSkip.cs

* Update AutoSkip.cs

* Settings apply to all segment types

* Update SegmentProvider

* Update configPage.html

Whoops

* Update Plugin.cs

* Update AutoSkip.cs

* Let’s call it missing instead

* Update BaseItemAnalyzerTask.cs

* Update BaseItemAnalyzerTask.cs

* Update BaseItemAnalyzerTask.cs

* Move "select" all below list

* Clarify button wording

* Update configPage.html

* Nope, long client list will hide it

* Simplify wording

* Update QueuedEpisode.cs

* fix unit test for ffmpeg7

* Add migration

* Restore DataContract

* update

* Update configPage.html

* remove analyzed status

* Update AutoSkip.cs

* Update configPage.html typo

* Store analyzed items in seasoninfo

* Update VisualizationController.cs

* update

* Update IntroSkipperDbContext.cs

* Add preview / recap delete

* This keeps changing itself

* Update SkipIntroController.cs

* Rather add it to be removed

---------

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: TwistedUmbrellaX <1173913+AbandonedCart@users.noreply.github.com>
Co-authored-by: Kilian von Pflugk <github@jumoog.io>
This commit is contained in:
rlauuzo 2024-11-21 15:42:55 +01:00 committed by GitHub
parent 6aa26fe9a7
commit 6ccf002e51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1340 additions and 1411 deletions

View File

@ -31,8 +31,7 @@ public class TestAudioFingerprinting
[InlineData(19, 2_465_585_877)]
public void TestBitCounting(int expectedBits, uint number)
{
var chromaprint = CreateChromaprintAnalyzer();
Assert.Equal(expectedBits, chromaprint.CountBits(number));
Assert.Equal(expectedBits, ChromaprintAnalyzer.CountBits(number));
}
[FactSkipFFmpegTests]
@ -86,7 +85,8 @@ public class TestAudioFingerprinting
{77, 5},
};
var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction);
var analyzer = CreateChromaprintAnalyzer();
var actual = analyzer.CreateInvertedIndex(Guid.NewGuid(), fpr);
Assert.Equal(expected, actual);
}
@ -127,12 +127,12 @@ public class TestAudioFingerprinting
var expected = new TimeRange[]
{
new(44.6310, 44.8072),
new(53.5905, 53.8070),
new(53.8458, 54.2024),
new(54.2611, 54.5935),
new(54.7098, 54.9293),
new(54.9294, 55.2590),
new(44.631042, 44.807167),
new(53.590521, 53.806979),
new(53.845833, 54.202417),
new(54.261104, 54.593479),
new(54.709792, 54.929312),
new(54.929396, 55.258979),
};
var range = new TimeRange(0, 60);

View File

@ -18,7 +18,7 @@ public class TestBlackFrames
var range = 1e-5;
var expected = new List<BlackFrame>();
expected.AddRange(CreateFrameSequence(2.04, 3));
expected.AddRange(CreateFrameSequence(2, 3));
expected.AddRange(CreateFrameSequence(5, 6));
expected.AddRange(CreateFrameSequence(8, 9.96));
@ -43,7 +43,7 @@ public class TestBlackFrames
var episode = QueueFile("credits.mp4");
episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds;
var result = analyzer.AnalyzeMediaFile(episode, 240, 30, 85);
var result = analyzer.AnalyzeMediaFile(episode, 240, 85);
Assert.NotNull(result);
Assert.InRange(result.Start, 300 - range, 300 + range);
}

View File

@ -1,121 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Linq;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.Analyzers
{
/// <summary>
/// Analyzer Helper.
/// </summary>
public class AnalyzerHelper
{
private readonly ILogger _logger;
private readonly double _silenceDetectionMinimumDuration;
/// <summary>
/// Initializes a new instance of the <see cref="AnalyzerHelper"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public AnalyzerHelper(ILogger logger)
{
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
_silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration;
_logger = logger;
}
/// <summary>
/// Adjusts the end timestamps of all intros so that they end at silence.
/// </summary>
/// <param name="episodes">QueuedEpisodes to adjust.</param>
/// <param name="originalIntros">Original introductions.</param>
/// <param name="mode">Analysis mode.</param>
/// <returns>Modified Intro Timestamps.</returns>
public IReadOnlyList<Segment> AdjustIntroTimes(
IReadOnlyList<QueuedEpisode> episodes,
IReadOnlyList<Segment> originalIntros,
AnalysisMode mode)
{
return originalIntros.Select(i => AdjustIntroForEpisode(episodes.FirstOrDefault(e => originalIntros.Any(i => i.EpisodeId == e.EpisodeId)), i, mode)).ToList();
}
private Segment AdjustIntroForEpisode(QueuedEpisode? episode, Segment originalIntro, AnalysisMode mode)
{
if (episode is null)
{
return new Segment(originalIntro.EpisodeId);
}
_logger.LogTrace("{Name} original intro: {Start} - {End}", episode.Name, originalIntro.Start, originalIntro.End);
var adjustedIntro = new Segment(originalIntro);
var originalIntroStart = new TimeRange(Math.Max(0, (int)originalIntro.Start - 5), (int)originalIntro.Start + 10);
var originalIntroEnd = new TimeRange((int)originalIntro.End - 10, Math.Min(episode.Duration, (int)originalIntro.End + 5));
if (!AdjustIntroBasedOnChapters(episode, adjustedIntro, originalIntroStart, originalIntroEnd) && mode == AnalysisMode.Introduction)
{
AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd);
}
return adjustedIntro;
}
private bool AdjustIntroBasedOnChapters(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroStart, TimeRange originalIntroEnd)
{
var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? [];
double previousTime = 0;
for (int i = 0; i <= chapters.Count; i++)
{
double currentTime = i < chapters.Count
? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds
: episode.Duration;
if (originalIntroStart.Start < previousTime && previousTime < originalIntroStart.End)
{
adjustedIntro.Start = previousTime;
_logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime);
}
if (originalIntroEnd.Start < currentTime && currentTime < originalIntroEnd.End)
{
adjustedIntro.End = currentTime;
_logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime);
return true;
}
previousTime = currentTime;
}
return false;
}
private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroEnd)
{
var silence = FFmpegWrapper.DetectSilence(episode, originalIntroEnd);
foreach (var currentRange in silence)
{
_logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, currentRange.Start, currentRange.End);
if (IsValidSilenceForIntroAdjustment(currentRange, originalIntroEnd, adjustedIntro))
{
adjustedIntro.End = currentRange.Start;
break;
}
}
}
private bool IsValidSilenceForIntroAdjustment(TimeRange silenceRange, TimeRange originalIntroEnd, Segment adjustedIntro)
{
return originalIntroEnd.Intersects(silenceRange) &&
silenceRange.Duration >= _silenceDetectionMinimumDuration &&
silenceRange.Start >= adjustedIntro.Start;
}
}
}

View File

@ -18,13 +18,9 @@ namespace IntroSkipper.Analyzers;
/// </summary>
public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFileAnalyzer
{
private static readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
private readonly TimeSpan _maximumError = new(0, 0, 4);
private readonly ILogger<BlackFrameAnalyzer> _logger = logger;
private readonly int _minimumCreditsDuration = _config.MinimumCreditsDuration;
private readonly int _maximumCreditsDuration = _config.MaximumCreditsDuration;
private readonly int _maximumMovieCreditsDuration = _config.MaximumMovieCreditsDuration;
private readonly int _blackFrameMinimumPercentage = _config.BlackFrameMinimumPercentage;
/// <inheritdoc />
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
@ -37,92 +33,40 @@ public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFile
throw new NotImplementedException("mode must equal Credits");
}
var creditTimes = new List<Segment>();
var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
bool isFirstEpisode = true;
var searchStart = 0.0;
double searchStart = _minimumCreditsDuration;
var searchDistance = 2 * _minimumCreditsDuration;
foreach (var episode in analysisQueue.Where(e => !e.GetAnalyzed(mode)))
foreach (var episode in episodesWithoutIntros)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration;
var chapters = Plugin.Instance!.GetChapters(episode.EpisodeId);
var lastSuitableChapter = chapters.LastOrDefault(c =>
if (!AnalyzeChapters(episode, out var credit))
{
var start = TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds;
return start >= _minimumCreditsDuration && start <= creditDuration;
});
if (lastSuitableChapter is not null)
if (searchStart < _config.MinimumCreditsDuration)
{
searchStart = TimeSpan.FromTicks(lastSuitableChapter.StartPositionTicks).TotalSeconds;
isFirstEpisode = false;
searchStart = FindSearchStart(episode);
}
if (isFirstEpisode)
{
var scanTime = episode.Duration - searchStart;
var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here.
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
while (frames.Length > 0) // While black frames are found increase searchStart
{
searchStart += searchDistance;
scanTime = episode.Duration - searchStart;
tr = new TimeRange(scanTime - 0.5, scanTime);
frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
if (searchStart > creditDuration)
{
searchStart = creditDuration;
break;
}
}
if (searchStart == _minimumCreditsDuration) // Skip if no black frames were found
{
continue;
}
isFirstEpisode = false;
}
var credit = AnalyzeMediaFile(
credit = AnalyzeMediaFile(
episode,
searchStart,
searchDistance,
_blackFrameMinimumPercentage);
_config.BlackFrameMinimumPercentage);
}
if (credit is null || !credit.Valid)
{
// If no credits were found, reset the first-episode search logic for the next episode in the sequence.
searchStart = _minimumCreditsDuration;
isFirstEpisode = true;
continue;
}
searchStart = episode.Duration - credit.Start + (0.5 * searchDistance);
creditTimes.Add(credit);
episode.SetAnalyzed(mode, true);
episode.IsAnalyzed = true;
await Plugin.Instance!.UpdateTimestampAsync(credit, mode).ConfigureAwait(false);
searchStart = episode.Duration - credit.Start + _config.MinimumCreditsDuration;
}
var analyzerHelper = new AnalyzerHelper(_logger);
var adjustedCreditTimes = analyzerHelper.AdjustIntroTimes(analysisQueue, creditTimes, mode);
await Plugin.Instance!.UpdateTimestamps(adjustedCreditTimes, mode).ConfigureAwait(false);
return analysisQueue;
}
@ -131,20 +75,18 @@ public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFile
/// </summary>
/// <param name="episode">Media file to analyze.</param>
/// <param name="searchStart">Search Start Piont.</param>
/// <param name="searchDistance">Search Distance.</param>
/// <param name="minimum">Percentage of the frame that must be black.</param>
/// <returns>Credits timestamp.</returns>
public Segment? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int searchDistance, int minimum)
public Segment? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int minimum)
{
// Start by analyzing the last N minutes of the file.
var searchDistance = 2 * _config.MinimumCreditsDuration;
var upperLimit = searchStart;
var lowerLimit = Math.Max(searchStart - searchDistance, _minimumCreditsDuration);
var lowerLimit = Math.Max(searchStart - searchDistance, _config.MinimumCreditsDuration);
var start = TimeSpan.FromSeconds(upperLimit);
var end = TimeSpan.FromSeconds(lowerLimit);
var firstFrameTime = 0.0;
var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration;
// Continue bisecting the end of the file until the range that contains the first black
// frame is smaller than the maximum permitted error.
while (start - end > _maximumError)
@ -177,7 +119,7 @@ public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFile
if (midpoint - TimeSpan.FromSeconds(lowerLimit) < _maximumError)
{
lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), _minimumCreditsDuration);
lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), _config.MinimumCreditsDuration);
// Reset end for a new search with the increased duration
end = TimeSpan.FromSeconds(lowerLimit);
@ -191,7 +133,7 @@ public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFile
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError)
{
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), creditDuration);
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), episode.Duration - episode.CreditsFingerprintStart);
// Reset start for a new search with the increased duration
start = TimeSpan.FromSeconds(upperLimit);
@ -206,4 +148,71 @@ public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFile
return null;
}
private bool AnalyzeChapters(QueuedEpisode episode, out Segment? segment)
{
// Get last chapter that falls within the valid credits duration range
var suitableChapters = Plugin.Instance!.GetChapters(episode.EpisodeId)
.Select(c => TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds)
.Where(s => s >= episode.CreditsFingerprintStart &&
s <= episode.Duration - _config.MinimumCreditsDuration)
.OrderByDescending(s => s).ToList();
// If suitable chapters found, use them to find the search start point
foreach (var chapterStart in suitableChapters)
{
// Check for black frames at chapter start
var startRange = new TimeRange(chapterStart, chapterStart + 1);
var hasBlackFramesAtStart = FFmpegWrapper.DetectBlackFrames(
episode,
startRange,
_config.BlackFrameMinimumPercentage).Length > 0;
if (!hasBlackFramesAtStart)
{
break;
}
// Verify no black frames before chapter start
var beforeRange = new TimeRange(chapterStart - 5, chapterStart - 4);
var hasBlackFramesBefore = FFmpegWrapper.DetectBlackFrames(
episode,
beforeRange,
_config.BlackFrameMinimumPercentage).Length > 0;
if (!hasBlackFramesBefore)
{
segment = new(episode.EpisodeId, new TimeRange(chapterStart, episode.Duration));
return true;
}
}
segment = null;
return false;
}
private double FindSearchStart(QueuedEpisode episode)
{
var searchStart = 3 * _config.MinimumCreditsDuration;
var scanTime = episode.Duration - searchStart;
var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here.
// Keep increasing search start time while black frames are found, to avoid false positives
while (FFmpegWrapper.DetectBlackFrames(episode, tr, _config.BlackFrameMinimumPercentage).Length > 0)
{
// Increase by 2x minimum credits duration each iteration
searchStart += 2 * _config.MinimumCreditsDuration;
scanTime = episode.Duration - searchStart;
tr = new TimeRange(scanTime - 0.5, scanTime);
// Don't search past the required credits duration from the end
if (searchStart > episode.Duration - episode.CreditsFingerprintStart)
{
searchStart = episode.Duration - episode.CreditsFingerprintStart;
break;
}
}
return searchStart;
}
}

View File

@ -25,6 +25,7 @@ namespace IntroSkipper.Analyzers;
public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyzer
{
private readonly ILogger<ChapterAnalyzer> _logger = logger;
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <inheritdoc />
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
@ -32,18 +33,23 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
AnalysisMode mode,
CancellationToken cancellationToken)
{
var skippableRanges = new List<Segment>();
var expression = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern :
Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern;
var expression = mode switch
{
AnalysisMode.Introduction => _config.ChapterAnalyzerIntroductionPattern,
AnalysisMode.Credits => _config.ChapterAnalyzerEndCreditsPattern,
AnalysisMode.Recap => _config.ChapterAnalyzerRecapPattern,
AnalysisMode.Preview => _config.ChapterAnalyzerPreviewPattern,
_ => throw new ArgumentOutOfRangeException(nameof(mode), $"Unexpected analysis mode: {mode}")
};
if (string.IsNullOrWhiteSpace(expression))
{
return analysisQueue;
}
foreach (var episode in analysisQueue.Where(e => !e.GetAnalyzed(mode)))
var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
foreach (var episode in episodesWithoutIntros)
{
if (cancellationToken.IsCancellationRequested)
{
@ -52,7 +58,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
var skipRange = FindMatchingChapter(
episode,
Plugin.Instance.GetChapters(episode.EpisodeId),
Plugin.Instance!.GetChapters(episode.EpisodeId),
expression,
mode);
@ -61,12 +67,10 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
continue;
}
skippableRanges.Add(skipRange);
episode.SetAnalyzed(mode, true);
episode.IsAnalyzed = true;
await Plugin.Instance!.UpdateTimestampAsync(skipRange, mode).ConfigureAwait(false);
}
await Plugin.Instance.UpdateTimestamps(skippableRanges, mode).ConfigureAwait(false);
return analysisQueue;
}
@ -91,12 +95,11 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
return null;
}
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var creditDuration = episode.IsMovie ? config.MaximumMovieCreditsDuration : config.MaximumCreditsDuration;
var reversed = mode != AnalysisMode.Introduction;
var creditDuration = episode.IsMovie ? _config.MaximumMovieCreditsDuration : _config.MaximumCreditsDuration;
var reversed = mode == AnalysisMode.Credits;
var (minDuration, maxDuration) = reversed
? (config.MinimumCreditsDuration, creditDuration)
: (config.MinimumIntroDuration, config.MaximumIntroDuration);
? (_config.MinimumCreditsDuration, creditDuration)
: (_config.MinimumIntroDuration, _config.MaximumIntroDuration);
// Check all chapters
for (int i = reversed ? count - 1 : 0; reversed ? i >= 0 : i < count; i += reversed ? -1 : 1)
@ -133,7 +136,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
var match = Regex.IsMatch(
chapter.Name,
expression,
RegexOptions.None,
RegexOptions.IgnoreCase,
TimeSpan.FromSeconds(1));
if (!match)

View File

@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Threading;
@ -15,87 +14,45 @@ using Microsoft.Extensions.Logging;
namespace IntroSkipper.Analyzers;
/// <summary>
/// Chromaprint audio analyzer.
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
/// </summary>
public class ChromaprintAnalyzer : IMediaFileAnalyzer
/// <param name="logger">Logger.</param>
public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFileAnalyzer
{
/// <summary>
/// Seconds of audio in one fingerprint point.
/// This value is defined by the Chromaprint library and should not be changed.
/// </summary>
private const double SamplesToSeconds = 0.1238;
private readonly int _minimumIntroDuration;
private readonly int _maximumDifferences;
private readonly int _invertedIndexShift;
private readonly double _maximumTimeSkip;
private readonly ILogger<ChromaprintAnalyzer> _logger;
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
private readonly ILogger<ChromaprintAnalyzer> _logger = logger;
private readonly Dictionary<Guid, Dictionary<uint, int>> _invertedIndexCache = [];
private AnalysisMode _analysisMode;
/// <summary>
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger)
{
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
_maximumDifferences = config.MaximumFingerprintPointDifferences;
_invertedIndexShift = config.InvertedIndexShift;
_maximumTimeSkip = config.MaximumTimeSkip;
_minimumIntroDuration = config.MinimumIntroDuration;
_logger = logger;
}
/// <inheritdoc />
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
// Episodes that were not analyzed.
var episodeAnalysisQueue = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
if (episodeAnalysisQueue.Count <= 1)
{
return analysisQueue;
}
_analysisMode = mode;
// All intros for this season.
var seasonIntros = new Dictionary<Guid, Segment>();
// Cache of all fingerprints for this season.
var fingerprintCache = new Dictionary<Guid, uint[]>();
// Episode analysis queue based on not analyzed episodes
var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue);
// Episodes that were analyzed and do not have an introduction.
var episodesWithoutIntros = episodeAnalysisQueue.Where(e => !e.GetAnalyzed(mode)).ToList();
_analysisMode = mode;
if (episodesWithoutIntros.Count == 0 || episodeAnalysisQueue.Count <= 1)
{
return analysisQueue;
}
var episodesWithFingerprint = new List<QueuedEpisode>(episodesWithoutIntros);
// Load fingerprints from cache if available.
episodesWithFingerprint.AddRange(episodeAnalysisQueue.Where(e => e.GetAnalyzed(mode) && File.Exists(FFmpegWrapper.GetFingerprintCachePath(e, mode))));
// Ensure at least two fingerprints are present.
if (episodesWithFingerprint.Count == 1)
{
var indexInAnalysisQueue = episodeAnalysisQueue.FindIndex(episode => episode == episodesWithoutIntros[0]);
episodesWithFingerprint.AddRange(episodeAnalysisQueue
.Where((episode, index) => Math.Abs(index - indexInAnalysisQueue) <= 1 && index != indexInAnalysisQueue));
}
seasonIntros = episodesWithFingerprint
.Where(e => e.GetAnalyzed(mode))
.ToDictionary(e => e.EpisodeId, e => Plugin.Instance!.GetSegmentByMode(e.EpisodeId, mode));
// Compute fingerprints for all episodes in the season
foreach (var episode in episodesWithFingerprint)
foreach (var episode in episodeAnalysisQueue)
{
try
{
@ -123,15 +80,14 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
}
// While there are still episodes in the queue
while (episodesWithoutIntros.Count > 0)
while (episodeAnalysisQueue.Count > 0)
{
// Pop the first episode from the queue
var currentEpisode = episodesWithoutIntros[0];
episodesWithoutIntros.RemoveAt(0);
episodesWithFingerprint.Remove(currentEpisode);
var currentEpisode = episodeAnalysisQueue[0];
episodeAnalysisQueue.RemoveAt(0);
// Search through all remaining episodes.
foreach (var remainingEpisode in episodesWithFingerprint)
foreach (var remainingEpisode in episodeAnalysisQueue)
{
// Compare the current episode to all remaining episodes in the queue.
var (currentIntro, remainingIntro) = CompareEpisodes(
@ -192,29 +148,17 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
break;
}
// If no intro is found at this point, the popped episode is not reinserted into the queue.
if (seasonIntros.ContainsKey(currentEpisode.EpisodeId))
// If an intro is found for this episode, adjust its times and save it else add it to the list of episodes without intros.
if (seasonIntros.TryGetValue(currentEpisode.EpisodeId, out var intro))
{
episodesWithFingerprint.Add(currentEpisode);
episodeAnalysisQueue.FirstOrDefault(x => x.EpisodeId == currentEpisode.EpisodeId)?.SetAnalyzed(mode, true);
currentEpisode.IsAnalyzed = true;
await Plugin.Instance!.UpdateTimestampAsync(intro, mode).ConfigureAwait(false);
}
}
// If cancellation was requested, report that no episodes were analyzed.
if (cancellationToken.IsCancellationRequested)
{
return analysisQueue;
}
// Adjust all introduction times.
var analyzerHelper = new AnalyzerHelper(_logger);
var adjustedSeasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, [.. seasonIntros.Values], _analysisMode);
await Plugin.Instance!.UpdateTimestamps(adjustedSeasonIntros, _analysisMode).ConfigureAwait(false);
return episodeAnalysisQueue;
}
/// <summary>
/// Analyze two episodes to find an introduction sequence shared between them.
/// </summary>
@ -302,8 +246,8 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
var rhsRanges = new List<TimeRange>();
// Generate inverted indexes for the left and right episodes.
var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, _analysisMode);
var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, _analysisMode);
var lhsIndex = CreateInvertedIndex(lhsId, lhsPoints);
var rhsIndex = CreateInvertedIndex(rhsId, rhsPoints);
var indexShifts = new HashSet<int>();
// For all audio points in the left episode, check if the right episode has a point which matches exactly.
@ -312,7 +256,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
{
var originalPoint = kvp.Key;
for (var i = -1 * _invertedIndexShift; i <= _invertedIndexShift; i++)
for (var i = -1 * _config.InvertedIndexShift; i <= _config.InvertedIndexShift; i++)
{
var modifiedPoint = (uint)(originalPoint + i);
@ -377,7 +321,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
var diff = lhs[lhsPosition] ^ rhs[rhsPosition];
// If the difference between the samples is small, flag both times as similar.
if (CountBits(diff) > _maximumDifferences)
if (CountBits(diff) > _config.MaximumFingerprintPointDifferences)
{
continue;
}
@ -394,23 +338,156 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
rhsTimes.Add(double.MaxValue);
// Now that both fingerprints have been compared at this shift, see if there's a contiguous time range.
var lContiguous = TimeRangeHelpers.FindContiguous(lhsTimes.ToArray(), _maximumTimeSkip);
if (lContiguous is null || lContiguous.Duration < _minimumIntroDuration)
var lContiguous = TimeRangeHelpers.FindContiguous([.. lhsTimes], _config.MaximumTimeSkip);
if (lContiguous is null || lContiguous.Duration < _config.MinimumIntroDuration)
{
return (new TimeRange(), new TimeRange());
}
// Since LHS had a contiguous time range, RHS must have one also.
var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), _maximumTimeSkip)!;
var rContiguous = TimeRangeHelpers.FindContiguous([.. rhsTimes], _config.MaximumTimeSkip)!;
return (lContiguous, rContiguous);
}
/// <summary>
/// Adjusts the end timestamps of all intros so that they end at silence.
/// </summary>
/// <param name="episode">QueuedEpisode to adjust.</param>
/// <param name="originalIntro">Original introduction.</param>
private Segment AdjustIntroTimes(
QueuedEpisode episode,
Segment originalIntro)
{
_logger.LogTrace(
"{Name} original intro: {Start} - {End}",
episode.Name,
originalIntro.Start,
originalIntro.End);
var originalIntroStart = new TimeRange(
Math.Max(0, (int)originalIntro.Start - 5),
(int)originalIntro.Start + 10);
var originalIntroEnd = new TimeRange(
(int)originalIntro.End - 10,
Math.Min(episode.Duration, (int)originalIntro.End + 5));
// Try to adjust based on chapters first, fall back to silence detection for intros
if (!AdjustIntroBasedOnChapters(episode, originalIntro, originalIntroStart, originalIntroEnd) &&
_analysisMode == AnalysisMode.Introduction)
{
AdjustIntroBasedOnSilence(episode, originalIntro, originalIntroEnd);
}
_logger.LogTrace(
"{Name} adjusted intro: {Start} - {End}",
episode.Name,
originalIntro.Start,
originalIntro.End);
return originalIntro;
}
private bool AdjustIntroBasedOnChapters(
QueuedEpisode episode,
Segment intro,
TimeRange originalIntroStart,
TimeRange originalIntroEnd)
{
var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? [];
double previousTime = 0;
for (int i = 0; i <= chapters.Count; i++)
{
double currentTime = i < chapters.Count
? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds
: episode.Duration;
if (IsTimeWithinRange(previousTime, originalIntroStart))
{
intro.Start = previousTime;
_logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime);
}
if (IsTimeWithinRange(currentTime, originalIntroEnd))
{
intro.End = currentTime;
_logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime);
return true;
}
previousTime = currentTime;
}
return false;
}
private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment intro, TimeRange originalIntroEnd)
{
var silenceRanges = FFmpegWrapper.DetectSilence(episode, originalIntroEnd);
foreach (var silenceRange in silenceRanges)
{
_logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, silenceRange.Start, silenceRange.End);
if (IsValidSilenceForIntroAdjustment(silenceRange, originalIntroEnd, intro))
{
intro.End = silenceRange.Start;
break;
}
}
}
private bool IsValidSilenceForIntroAdjustment(
TimeRange silenceRange,
TimeRange originalIntroEnd,
Segment adjustedIntro)
{
return originalIntroEnd.Intersects(silenceRange) &&
silenceRange.Duration >= _config.SilenceDetectionMinimumDuration &&
silenceRange.Start >= adjustedIntro.Start;
}
private static bool IsTimeWithinRange(double time, TimeRange range)
{
return range.Start < time && time < range.End;
}
/// <summary>
/// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at.
/// </summary>
/// <param name="id">Episode ID.</param>
/// <param name="fingerprint">Chromaprint fingerprint.</param>
/// <returns>Inverted index.</returns>
public Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint)
{
if (_invertedIndexCache.TryGetValue(id, out var cached))
{
return cached;
}
var invIndex = new Dictionary<uint, int>();
for (int i = 0; i < fingerprint.Length; i++)
{
// Get the current point.
var point = fingerprint[i];
// Append the current sample's timecode to the collection for this point.
invIndex[point] = i;
}
_invertedIndexCache[id] = invIndex;
return invIndex;
}
/// <summary>
/// Count the number of bits that are set in the provided number.
/// </summary>
/// <param name="number">Number to count bits in.</param>
/// <returns>Number of bits that are equal to 1.</returns>
public int CountBits(uint number)
public static int CountBits(uint number)
{
return BitOperations.PopCount(number);
}

View File

@ -1,6 +1,7 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System.Collections.Generic;
using System.Diagnostics;
using IntroSkipper.Data;
using MediaBrowser.Model.Plugins;
@ -21,11 +22,6 @@ public class PluginConfiguration : BasePluginConfiguration
// ===== Analysis settings =====
/// <summary>
/// Gets or sets the max degree of parallelism used when analyzing episodes.
/// </summary>
public int MaxParallelism { get; set; } = 2;
/// <summary>
/// Gets or sets the comma separated list of library names to analyze.
/// </summary>
@ -47,15 +43,10 @@ public class PluginConfiguration : BasePluginConfiguration
public string ClientList { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether to scan for intros during a scheduled task.
/// Gets or sets a value indicating whether to automatically scan newly added items.
/// </summary>
public bool AutoDetectIntros { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether to scan for credits during a scheduled task.
/// </summary>
public bool AutoDetectCredits { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether to analyze season 0.
/// </summary>
@ -87,6 +78,26 @@ public class PluginConfiguration : BasePluginConfiguration
// ===== Custom analysis settings =====
/// <summary>
/// Gets or sets a value indicating whether Introductions should be analyzed.
/// </summary>
public bool ScanIntroduction { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether Credits should be analyzed.
/// </summary>
public bool ScanCredits { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether Recaps should be analyzed.
/// </summary>
public bool ScanRecap { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether Previews should be analyzed.
/// </summary>
public bool ScanPreview { get; set; } = true;
/// <summary>
/// Gets or sets the percentage of each episode's audio track to analyze.
/// </summary>
@ -131,20 +142,32 @@ public class PluginConfiguration : BasePluginConfiguration
/// Gets or sets the regular expression used to detect introduction chapters.
/// </summary>
public string ChapterAnalyzerIntroductionPattern { get; set; } =
@"(^|\s)(Intro|Introduction|OP|Opening)(\s|$)";
@"(^|\s)(Intro|Introduction|OP|Opening)(?!\sEnd)(\s|$)";
/// <summary>
/// Gets or sets the regular expression used to detect ending credit chapters.
/// </summary>
public string ChapterAnalyzerEndCreditsPattern { get; set; } =
@"(^|\s)(Credits?|ED|Ending|End|Outro)(\s|$)";
@"(^|\s)(Credits?|ED|Ending|Outro)(?!\sEnd)(\s|$)";
/// <summary>
/// Gets or sets the regular expression used to detect Preview chapters.
/// </summary>
public string ChapterAnalyzerPreviewPattern { get; set; } =
@"(^|\s)(Preview|PV|Sneak\s?Peek|Coming\s?(Up|Soon)|Next\s+(time|on|episode)|Extra|Teaser|Trailer)(?!\sEnd)(\s|:|$)";
/// <summary>
/// Gets or sets the regular expression used to detect Recap chapters.
/// </summary>
public string ChapterAnalyzerRecapPattern { get; set; } =
@"(^|\s)(Re?cap|Sum{1,2}ary|Prev(ious(ly)?)?|(Last|Earlier)(\s\w+)?|Catch[ -]up)(?!\sEnd)(\s|:|$)";
// ===== Playback settings =====
/// <summary>
/// Gets or sets a value indicating whether to show the skip intro button.
/// </summary>
public bool SkipButtonEnabled { get; set; } = false;
public bool SkipButtonEnabled { get; set; }
/// <summary>
/// Gets a value indicating whether to show the skip intro warning.
@ -156,11 +179,26 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary>
public bool AutoSkip { get; set; }
/// <summary>
/// Gets or sets the list of segment types to auto skip.
/// </summary>
public string TypeList { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether credits should be automatically skipped.
/// </summary>
public bool AutoSkipCredits { get; set; }
/// <summary>
/// Gets or sets a value indicating whether recap should be automatically skipped.
/// </summary>
public bool AutoSkipRecap { get; set; }
/// <summary>
/// Gets or sets a value indicating whether preview should be automatically skipped.
/// </summary>
public bool AutoSkipPreview { get; set; }
/// <summary>
/// Gets or sets the seconds before the intro starts to show the skip prompt at.
/// </summary>
@ -191,11 +229,6 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary>
public int SecondsOfIntroStartToPlay { get; set; }
/// <summary>
/// Gets or sets the amount of credit at start to play (in seconds).
/// </summary>
public int SecondsOfCreditsStartToPlay { get; set; }
// ===== Internal algorithm settings =====
/// <summary>
@ -240,12 +273,12 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary>
/// Gets or sets the notification text sent after automatically skipping an introduction.
/// </summary>
public string AutoSkipNotificationText { get; set; } = "Intro skipped";
public string AutoSkipNotificationText { get; set; } = "Segment skipped";
/// <summary>
/// Gets or sets the notification text sent after automatically skipping credits.
/// Gets or sets the max degree of parallelism used when analyzing episodes.
/// </summary>
public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
public int MaxParallelism { get; set; } = 2;
/// <summary>
/// Gets or sets the number of threads for a ffmpeg process.

View File

@ -14,8 +14,10 @@ namespace IntroSkipper.Configuration;
/// <param name="creditsText">Skip button end credits text.</param>
/// <param name="autoSkip">Auto Skip Intro.</param>
/// <param name="autoSkipCredits">Auto Skip Credits.</param>
/// <param name="autoSkipRecap">Auto Skip Recap.</param>
/// <param name="autoSkipPreview">Auto Skip Preview.</param>
/// <param name="clientList">Auto Skip Clients.</param>
public class UserInterfaceConfiguration(bool visible, string introText, string creditsText, bool autoSkip, bool autoSkipCredits, string clientList)
public class UserInterfaceConfiguration(bool visible, string introText, string creditsText, bool autoSkip, bool autoSkipCredits, bool autoSkipRecap, bool autoSkipPreview, string clientList)
{
/// <summary>
/// Gets or sets a value indicating whether to show the skip intro button.
@ -42,6 +44,16 @@ public class UserInterfaceConfiguration(bool visible, string introText, string c
/// </summary>
public bool AutoSkipCredits { get; set; } = autoSkipCredits;
/// <summary>
/// Gets or sets a value indicating whether auto skip recap.
/// </summary>
public bool AutoSkipRecap { get; set; } = autoSkipRecap;
/// <summary>
/// Gets or sets a value indicating whether auto skip preview.
/// </summary>
public bool AutoSkipPreview { get; set; } = autoSkipPreview;
/// <summary>
/// Gets or sets a value indicating clients to auto skip for.
/// </summary>

View File

@ -32,30 +32,20 @@
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoDetectIntros" type="checkbox" is="emby-checkbox" />
<span>Automatically Scan Intros</span>
<span>Automatically Analyze New Media</span>
</label>
<div class="fieldDescription">If enabled, introductions will be automatically analyzed for new media</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoDetectCredits" type="checkbox" is="emby-checkbox" />
<span>Automatically Scan Credits</span>
</label>
<div class="fieldDescription">
If enabled, credits will be automatically analyzed for new media
<div class="fieldDescription">If enabled, new media will be automatically analyzed for skippable segments when added to the library
<br />
<br />
Note: Not selecting at least one automatic detection type will disable automatic scans. To configure the scheduled task, see <a is="emby-linkbutton" class="button-link" href="#/dashboard/tasks">scheduled tasks</a>.
Note: To configure the scheduled task, see <a is="emby-linkbutton" class="button-link" href="#/dashboard/tasks">scheduled tasks</a>.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="UpdateMediaSegments" type="checkbox" is="emby-checkbox" />
<span>Update Segments for New Media During Scan</span>
<span>Update Missing Segments During Scan</span>
</label>
<div class="fieldDescription">
@ -108,6 +98,34 @@
Per the jellyfin MediaSegments API, records must be updated individually and may be slow to regenerate.
</p>
<div class="checkboxContainer">
<label class="emby-checkbox-label">
<input id="ScanIntroduction" type="checkbox" is="emby-checkbox" />
<span>Identify Introductions</span>
</label>
</div>
<div class="checkboxContainer">
<label class="emby-checkbox-label">
<input id="ScanCredits" type="checkbox" is="emby-checkbox" />
<span>Identify Credits</span>
</label>
</div>
<div class="checkboxContainer">
<label class="emby-checkbox-label">
<input id="ScanRecap" type="checkbox" is="emby-checkbox" />
<span>Identify Recaps</span>
</label>
</div>
<div class="checkboxContainer">
<label class="emby-checkbox-label">
<input id="ScanPreview" type="checkbox" is="emby-checkbox" />
<span>Identify Previews</span>
</label>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="AnalysisPercent"> Percent of audio to analyze </label>
<input id="AnalysisPercent" type="number" is="emby-input" min="1" max="90" />
@ -188,6 +206,43 @@
</div>
</details>
<details id="chapters">
<summary>Chapter Detection Options</summary>
<br />
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ChapterAnalyzerIntroductionPattern"> Introductions </label>
<input id="ChapterAnalyzerIntroductionPattern" type="text" placeholder="(^|\s)(Intro|Introduction|OP|Opening)(?!\sEnd)(\s|$)" is="emby-input" />
<div class="fieldDescription">Enter a regular expression to detect introduction chapters.
<br />Default: <code>(^|\s)(Intro|Introduction|OP|Opening)(?!\sEnd)(\s|$)</code>
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ChapterAnalyzerEndCreditsPattern"> Credits </label>
<input id="ChapterAnalyzerEndCreditsPattern" type="text" placeholder="(^|\s)(Credits?|ED|Ending|Outro)(?!\sEnd)(\s|$)" is="emby-input" />
<div class="fieldDescription">Enter a regular expression to detect credits chapters.
<br />Default: <code>(^|\s)(Credits?|ED|Ending|Outro)(?!\sEnd)(\s|$)</code>
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ChapterAnalyzerPreviewPattern"> Preview </label>
<input id="ChapterAnalyzerPreviewPattern" type="text" placeholder="(^|\s)(Preview|PV|Sneak\s?Peek|Coming\s?(Up|Soon)|Next\s+(time|on|episode)|Teaser|Trailer)(?!\sEnd)(\s|:|$)" is="emby-input" />
<div class="fieldDescription">Enter a regular expression to detect preview chapters.
<br />Default: <code>(^|\s)(Preview|PV|Sneak\s?Peek|Coming\s?(Up|Soon)|Next\s+(time|on|episode)|Teaser|Trailer)(?!\sEnd)(\s|:|$)</code>
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ChapterAnalyzerRecapPattern"> Recaps </label>
<input id="ChapterAnalyzerRecapPattern" type="text" placeholder="(^|\s)(Re?cap|Sum{1,2}ary|Prev(ious(ly)?)?|(Last|Earlier)(\s\w+)?|Catch[ -]up)(?!\sEnd)(\s|:|$)" is="emby-input" />
<div class="fieldDescription">Enter a regular expression to detect recap chapters.
<br />Default: <code>(^|\s)(Re?cap|Sum{1,2}ary|Prev(ious(ly)?)?|(Last|Earlier)(\s\w+)?|Catch[ -]up)(?!\sEnd)(\s|:|$)</code>
</div>
</div>
</details>
<details id="detection">
<summary>Process Configuration</summary>
@ -251,76 +306,57 @@
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoSkip" type="checkbox" is="emby-checkbox" />
<span>Automatically Skip Intros</span>
<span>Automatically Skip for All Clients</span>
</label>
<div class="fieldDescription">
If checked, intros will be automatically skipped for <b>all</b> clients.<br />
Note: Cannot be disabled from client popup (gear icon) settings.<br />
If you access Jellyfin through a reverse proxy, it must be configured to proxy websockets.<br />
</div>
<div class="AutoSkipClientListContainer">
<div class="AutoSkipClientList">
<h3 class="checkboxListLabel">Limit auto skip to the following clients</h3>
<div class="checkboxList paperList" style="padding: 0.5em 1em" id="autoSkipCheckboxes"></div>
</div>
<label class="inputLabel" for="ClientList"></label>
<input id="ClientList" type="hidden" is="emby-input" />
</div>
<div class="AutoSkipTypeListContainer">
<div class="AutoSkipTypeList">
<h3 class="checkboxListLabel">Auto skip the following types</h3>
<div class="checkboxList paperList" style="padding: 0.5em 1em" id="autoSkipTypeCheckboxes"></div>
</div>
<label class="inputLabel" for="TypeList"></label>
<input id="TypeList" type="hidden" is="emby-input" />
</div>
<div id="divSkipFirstEpisode" class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="SkipFirstEpisode" type="checkbox" is="emby-checkbox" />
<span>Play Intro for First Episode of a Season</span>
<span>Play Segments for First Episode of a Season</span>
</label>
<div class="fieldDescription">If checked, auto skip will play the introduction of the first episode in a season.<br /></div>
<br />
<div class="fieldDescription">If checked, auto skip will play the segments of the first episode in a season.</div>
</div>
<div id="divSecondsOfIntroStartToPlay" class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SecondsOfIntroStartToPlay"> Intro skip delay (in seconds) </label>
<label class="inputLabel inputLabelUnfocused" for="SecondsOfIntroStartToPlay"> Segment skip delay (in seconds) </label>
<input id="SecondsOfIntroStartToPlay" type="number" is="emby-input" min="0" />
<div class="fieldDescription">Seconds of introduction start that should be played. Defaults to 0.</div>
<br />
<div class="fieldDescription">Seconds of segment start that should be played. Defaults to 0.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RemainingSecondsOfIntro"> Intro playback duration (in seconds) </label>
<label class="inputLabel inputLabelUnfocused" for="RemainingSecondsOfIntro"> Segment playback duration (in seconds) </label>
<input id="RemainingSecondsOfIntro" type="number" is="emby-input" min="0" />
<div class="fieldDescription">Seconds of introduction ending that should be played. Defaults to 2.</div>
<div class="fieldDescription">Seconds of segment ending that should be played. Defaults to 2.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoSkipCredits" type="checkbox" is="emby-checkbox" />
<span>Automatically Skip Credits</span>
</label>
<div class="fieldDescription">
If checked, credits will be automatically skipped for <b>all</b> clients.<br />
Note: Cannot be disabled from client popup (gear icon) settings.<br />
If you access Jellyfin through a reverse proxy, it must be configured to proxy websockets.<br />
</div>
</div>
<div id="divSecondsOfCreditsStartToPlay" class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SecondsOfCreditsStartToPlay"> Credit skip delay (in seconds) </label>
<input id="SecondsOfCreditsStartToPlay" type="number" is="emby-input" min="0" />
<div class="fieldDescription">Seconds of credits start that should be played. Defaults to 0.</div>
<br />
</div>
<details id="AutoSkipClientList" style="padding-bottom: 1em">
<summary>Auto Skip Client List</summary>
<br />
<div class="checkboxList paperList" style="padding: 0.5em 1em" id="autoSkipCheckboxes"></div>
<label class="inputLabel" for="ClientList"></label>
<input id="ClientList" type="hidden" is="emby-input" />
<div class="fieldDescription">Clients enabled in this list will <b>always skip automatically</b>, regardless of the button settings below.</div>
</details>
<div id="SkipButtonContainer" class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="SkipButtonEnabled" type="checkbox" is="emby-checkbox" />
<span id="SkipButtonVisibleLabel">Show All Skip Buttons</span>
<span id="SkipButtonVisibleLabel">Show Segment Skip Buttons</span>
</label>
<div class="fieldDescription">
(<b>Restart required!</b>) If checked, a skip button will be added <b>to the server</b> and displayed according to the settings below.<br />
<b style="color: red">Restart required!</b> If checked, a skip button will be added <b>to the server</b> according to the UI settings.<br />
This button is <b>separate</b> from the Media Segment Actions in Jellyfin 10.10 and compatible clients.<br />
</div>
</div>
@ -379,12 +415,6 @@
<input id="AutoSkipNotificationText" type="text" is="emby-input" />
<div class="fieldDescription">Message shown after automatically skipping an introduction. Leave blank to disable notification.</div>
</div>
<div id="divAutoSkipCreditsNotificationText" class="inputContainer">
<label class="inputLabel" for="AutoSkipCreditsNotificationText"> Auto skip credits notification message </label>
<input id="AutoSkipCreditsNotificationText" type="text" is="emby-input" />
<div class="fieldDescription">Message shown after automatically skipping credits. Leave blank to disable notification.</div>
</div>
</details>
</fieldset>
@ -439,10 +469,26 @@
<option value="None">None</option>
</select>
</label>
<label for="actionRecap" style="margin-right: 1.5em; display: inline-block">
<span>Recap analysis</span>
<select is="emby-select" id="actionRecap" class="emby-select-withcolor emby-select">
<option value="Default">Default</option>
<option value="Chapter">Chapter</option>
<option value="None">None</option>
</select>
</label>
<label for="actionPreview" style="margin-right: 1.5em; display: inline-block">
<span>Preview analysis</span>
<select is="emby-select" id="actionPreview" class="emby-select-withcolor emby-select">
<option value="Default">Default</option>
<option value="Chapter">Chapter</option>
<option value="None">None</option>
</select>
</label>
</div>
<br />
<button is="emby-button" id="saveAnalyzerActions" class="raised button-submit block emby-button" style="display: none">Apply changes.</button>
<button is="emby-button" id="saveAnalyzerActions" class="raised button-submit block emby-button" style="display: none">Apply Changes</button>
</div>
<br />
@ -483,6 +529,30 @@
<input type="number" id="editLeftCreditEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
</div>
<div class="inlineForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="recapStart">Recap Start</label>
<input type="text" id="editLeftRecapEpisodeStartDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editLeftRecapEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="recapEnd">Recap End</label>
<input type="text" id="editLeftRecapEpisodeEndDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editLeftRecapEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
</div>
<div class="inlineForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="previewStart">Preview Start</label>
<input type="text" id="editLeftPreviewEpisodeStartDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editLeftPreviewEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="previewEnd">Preview End</label>
<input type="text" id="editLeftPreviewEpisodeEndDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editLeftPreviewEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
</div>
<br />
<div id="rightEpisodeEditor">
<h4 style="margin: 0" id="editRightEpisodeTitle"></h4>
@ -511,6 +581,30 @@
<input type="number" id="editRightCreditEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
</div>
<div class="inlineForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="recapStart">Recap Start</label>
<input type="text" id="editRightRecapEpisodeStartDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editRightRecapEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="recapEnd">Recap End</label>
<input type="text" id="editRightRecapEpisodeEndDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editRightRecapEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
</div>
<div class="inlineForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="previewStart">Preview Start</label>
<input type="text" id="editRightPreviewEpisodeStartDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editRightPreviewEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="previewEnd">Preview End</label>
<input type="text" id="editRightPreviewEpisodeEndDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editRightPreviewEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
</div>
<br />
</div>
<button is="emby-button" id="btnUpdateTimestamps" class="raised button-submit block emby-button" type="button">Update timestamps</button>
@ -592,14 +686,24 @@
<br />
</div>
<div style="display: flex; align-items: center;">
<div>
<button is="emby-button" class="button-submit emby-button" id="btnEraseIntroTimestamps">Erase all introduction timestamps (globally)</button>
<br />
<button is="emby-button" class="button-submit emby-button" id="btnEraseRecapTimestamps">Erase all recap timestamps (globally)</button>
<br />
<button is="emby-button" class="button-submit emby-button" id="btnEraseCreditTimestamps">Erase all end credits timestamps (globally)</button>
<br />
<br />
<button is="emby-button" class="button-submit emby-button" id="btnErasePreviewTimestamps">Erase all preview timestamps (globally)</button>
</div>
<div>
<input type="checkbox" id="eraseModeCacheCheckbox" style="margin-left: 10px" />
<label for="eraseModeCacheCheckbox" style="margin-left: 5px">Erase cached fingerprint files</label>
<label for="eraseModeCacheCheckbox" style="margin-left: 5px">Erase global cached fingerprint files</label>
</div>
</div>
<br />
<br />
</details>
@ -640,7 +744,9 @@
var support = document.querySelector("details#support");
var storage = document.querySelector("details#storage");
var btnEraseIntroTimestamps = document.querySelector("button#btnEraseIntroTimestamps");
var btnEraseRecapTimestamps = document.querySelector("button#btnEraseRecapTimestamps");
var btnEraseCreditTimestamps = document.querySelector("button#btnEraseCreditTimestamps");
var btnErasePreviewTimestamps = document.querySelector("button#btnErasePreviewTimestamps");
// all plugin configuration fields that can be get or set with .value (i.e. strings or numbers).
var configurationFields = [
@ -662,23 +768,28 @@
"HidePromptAdjustment",
"RemainingSecondsOfIntro",
"SecondsOfIntroStartToPlay",
"SecondsOfCreditsStartToPlay",
// internals
"SilenceDetectionMaximumNoise",
"SilenceDetectionMinimumDuration",
"ChapterAnalyzerIntroductionPattern",
"ChapterAnalyzerEndCreditsPattern",
"ChapterAnalyzerPreviewPattern",
"ChapterAnalyzerRecapPattern",
"TypeList",
// UI customization
"SkipButtonIntroText",
"SkipButtonEndCreditsText",
"AutoSkipNotificationText",
"AutoSkipCreditsNotificationText",
];
var booleanConfigurationFields = ["AutoDetectIntros", "AutoDetectCredits", "AnalyzeMovies", "AnalyzeSeasonZero", "SelectAllLibraries", "UpdateMediaSegments", "RebuildMediaSegments", "CacheFingerprints", "AutoSkip", "AutoSkipCredits", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonEnabled"];
var booleanConfigurationFields = ["AutoDetectIntros", "AnalyzeMovies", "AnalyzeSeasonZero", "SelectAllLibraries", "UpdateMediaSegments", "RebuildMediaSegments", "ScanIntroduction", "ScanCredits", "ScanRecap", "ScanPreview", "CacheFingerprints", "AutoSkip", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonEnabled"];
// visualizer elements
var analyzerActionsSection = document.querySelector("div#analyzerActionsSection");
var actionIntro = analyzerActionsSection.querySelector("select#actionIntro");
var actionCredits = analyzerActionsSection.querySelector("select#actionCredits");
var actionRecap = analyzerActionsSection.querySelector("select#actionRecap");
var actionPreview = analyzerActionsSection.querySelector("select#actionPreview");
var saveAnalyzerActionsButton = analyzerActionsSection.querySelector("button#saveAnalyzerActions");
var canvas = document.querySelector("canvas#troubleshooter");
var selectShow = document.querySelector("select#troubleshooterShow");
@ -711,15 +822,11 @@
var librariesContainer = document.querySelector("div.folderAccessListContainer");
var skipFirstEpisode = document.querySelector("div#divSkipFirstEpisode");
var secondsOfIntroStartToPlay = document.querySelector("div#divSecondsOfIntroStartToPlay");
var autoSkipClientList = document.getElementById("AutoSkipClientList");
var secondsOfCreditsStartToPlay = document.querySelector("div#divSecondsOfCreditsStartToPlay");
var autoSkipClientList = document.querySelector("div.AutoSkipClientListContainer");
var movieCreditsDuration = document.getElementById("movieCreditsDuration");
var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText");
var autoSkipCredits = document.querySelector("input#AutoSkipCredits");
var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText");
function skipButtonVisibleChanged() {
if (autoSkip.checked && autoSkipCredits.checked) {
if (autoSkip.checked) {
skipButtonSettings.style.display = "none";
} else if (skipButtonVisible.checked) {
skipButtonSettings.style.display = "unset";
@ -730,56 +837,20 @@
skipButtonVisible.addEventListener("change", skipButtonVisibleChanged);
function skipButtonVisibleText() {
if (autoSkip.checked && autoSkipCredits.checked) {
function autoSkipChanged() {
if (autoSkip.checked) {
autoSkipClientList.style.display = "none";
skipButtonVisibleLabel.textContent = "Button unavailable due to auto skip";
} else if (autoSkip.checked) {
autoSkipClientList.style.display = "unset";
autoSkipClientList.style.width = "100%";
skipButtonVisibleLabel.textContent = "Show Skip Credit Button";
} else if (autoSkipCredits.checked) {
autoSkipClientList.style.display = "unset";
autoSkipClientList.style.width = "100%";
skipButtonVisibleLabel.textContent = "Show Skip Intro Button";
} else {
autoSkipClientList.style.display = "unset";
autoSkipClientList.style.width = "100%";
skipButtonVisibleLabel.textContent = "Show All Skip Buttons";
skipButtonVisibleLabel.textContent = "Show Segment Skip Buttons";
}
skipButtonVisibleChanged();
}
function autoSkipChanged() {
if (autoSkip.checked) {
skipFirstEpisode.style.display = "unset";
autoSkipNotificationText.style.display = "unset";
secondsOfIntroStartToPlay.style.display = "unset";
} else {
skipFirstEpisode.style.display = "none";
autoSkipNotificationText.style.display = "none";
secondsOfIntroStartToPlay.style.display = "none";
}
skipButtonVisibleText();
}
autoSkip.addEventListener("change", autoSkipChanged);
function autoSkipCreditsChanged() {
if (autoSkipCredits.checked) {
autoSkipCreditsNotificationText.style.display = "unset";
secondsOfCreditsStartToPlay.style.display = "unset";
} else {
autoSkipCreditsNotificationText.style.display = "none";
secondsOfCreditsStartToPlay.style.display = "none";
}
skipButtonVisibleText();
}
autoSkipCredits.addEventListener("change", autoSkipCreditsChanged);
skipButtonVisibleText(); // run once on launch for legacy installs
function selectAllLibrariesChanged() {
if (selectAllLibraries.checked) {
librariesContainer.style.display = "none";
@ -800,12 +871,12 @@
const container = document.getElementById(containerId);
const checkedItems = new Set(document.getElementById(textFieldId).value.split(", ").filter(Boolean));
const fragment = document.createDocumentFragment();
items.forEach((item) => {
for (const item of items) {
const label = document.createElement("label");
label.className = "emby-checkbox-label";
label.innerHTML = '<input type="checkbox" is="emby-checkbox"' + (checkedItems.has(item) ? " checked" : "") + ">" + '<span class="checkboxLabel">' + item + "</span>";
fragment.appendChild(label);
});
}
container.innerHTML = "";
container.appendChild(fragment);
container.addEventListener(
@ -823,6 +894,11 @@
generateCheckboxList(devices, "autoSkipCheckboxes", "ClientList");
}
async function generateAutoSkipTypeList() {
const types = ["Introduction", "Credits", "Recap", "Preview"];
generateCheckboxList(types, "autoSkipTypeCheckboxes", "TypeList");
}
async function populateLibraries() {
const response = await getJson("Library/VirtualFolders");
const tvLibraries = response.filter((item) => item.CollectionType === undefined || item.CollectionType === "tvshows" || item.CollectionType === "movies");
@ -975,9 +1051,11 @@
Dashboard.showLoadingMsg();
// show the analyzer actions editor.
saveAnalyzerActionsButton.style.display = "block";
const analyzerActions = (await getJson("Intros/AnalyzerActions/" + encodeURI(selectSeason.value))) || { Introduction: "Default", Credits: "Default" };
const analyzerActions = await getJson("Intros/AnalyzerActions/" + encodeURI(selectSeason.value));
actionIntro.value = analyzerActions.Introduction || "Default";
actionCredits.value = analyzerActions.Credits || "Default";
actionRecap.value = analyzerActions.Recap || "Default";
actionPreview.value = analyzerActions.Preview || "Default";
analyzerActionsSection.style.display = "unset";
// show the erase season button
@ -1030,7 +1108,7 @@
timestampError.value += selectEpisode2.value + " fingerprints missing or incomplete.\n";
}
if (timestampError.value == "") {
if (timestampError.value === "") {
timestampErrorDiv.style.display = "none";
} else {
timestampErrorDiv.style.display = "unset";
@ -1065,7 +1143,7 @@
rightEpisodeEditor.style.display = "none";
if (timestampError.value == "") {
if (timestampError.value === "") {
timestampErrorDiv.style.display = "none";
} else {
timestampErrorDiv.style.display = "unset";
@ -1078,13 +1156,17 @@
// Try to get the timestamps of each intro, falling back a default value of zero if no intro was found
const leftEpisodeJson = await getJson("Episode/" + selectShow.value + "/Timestamps");
// Update the editor for the first and second episodes
// Update the editor for the movie
timestampEditor.style.display = "unset";
document.querySelector("#editLeftEpisodeTitle").textContent = selectShow.value;
document.querySelector("#editLeftIntroEpisodeStartEdit").value = leftEpisodeJson.Introduction.Start;
document.querySelector("#editLeftIntroEpisodeEndEdit").value = leftEpisodeJson.Introduction.End;
document.querySelector("#editLeftCreditEpisodeStartEdit").value = leftEpisodeJson.Credits.Start;
document.querySelector("#editLeftCreditEpisodeEndEdit").value = leftEpisodeJson.Credits.End;
document.querySelector("#editLeftRecapEpisodeStartEdit").value = leftEpisodeJson.Recap.Start;
document.querySelector("#editLeftRecapEpisodeEndEdit").value = leftEpisodeJson.Recap.End;
document.querySelector("#editLeftPreviewEpisodeStartEdit").value = leftEpisodeJson.Preview.Start;
document.querySelector("#editLeftPreviewEpisodeEndEdit").value = leftEpisodeJson.Preview.End;
// Update display inputs
const inputs = document.querySelectorAll('#timestampEditor input[type="number"]');
@ -1149,13 +1231,19 @@
document.querySelector("#editLeftIntroEpisodeEndEdit").value = leftEpisodeJson.Introduction.End;
document.querySelector("#editLeftCreditEpisodeStartEdit").value = leftEpisodeJson.Credits.Start;
document.querySelector("#editLeftCreditEpisodeEndEdit").value = leftEpisodeJson.Credits.End;
document.querySelector("#editLeftRecapEpisodeStartEdit").value = leftEpisodeJson.Recap.Start;
document.querySelector("#editLeftRecapEpisodeEndEdit").value = leftEpisodeJson.Recap.End;
document.querySelector("#editLeftPreviewEpisodeStartEdit").value = leftEpisodeJson.Preview.Start;
document.querySelector("#editLeftPreviewEpisodeEndEdit").value = leftEpisodeJson.Preview.End;
document.querySelector("#editRightEpisodeTitle").textContent = rightEpisode.text;
document.querySelector("#editRightIntroEpisodeStartEdit").value = rightEpisodeJson.Introduction.Start;
document.querySelector("#editRightIntroEpisodeEndEdit").value = rightEpisodeJson.Introduction.End;
document.querySelector("#editRightCreditEpisodeStartEdit").value = rightEpisodeJson.Credits.Start;
document.querySelector("#editRightCreditEpisodeEndEdit").value = rightEpisodeJson.Credits.End;
document.querySelector("#editRightRecapEpisodeStartEdit").value = rightEpisodeJson.Recap.Start;
document.querySelector("#editRightRecapEpisodeEndEdit").value = rightEpisodeJson.Recap.End;
document.querySelector("#editRightPreviewEpisodeStartEdit").value = rightEpisodeJson.Preview.Start;
document.querySelector("#editRightPreviewEpisodeEndEdit").value = rightEpisodeJson.Preview.End;
// Update display inputs
const inputs = document.querySelectorAll('#timestampEditor input[type="number"]');
inputs.forEach((input) => {
@ -1227,14 +1315,14 @@
switch (e.key) {
case "ArrowDown":
if (timestampError.value != "") {
if (timestampError.value !== "") {
// if the control key is pressed, shift LHS by 10s. Otherwise, shift by 1.
offsetDelta = e.ctrlKey ? 10 / 0.1238 : 1;
}
break;
case "ArrowUp":
if (timestampError.value != "") {
if (timestampError.value !== "") {
offsetDelta = e.ctrlKey ? -10 / 0.1238 : -1;
}
break;
@ -1251,11 +1339,11 @@
return;
}
if (offsetDelta != 0) {
if (offsetDelta !== 0) {
txtOffset.value = Number(txtOffset.value) + Math.floor(offsetDelta);
}
if (episodeDelta != 0) {
if (episodeDelta !== 0) {
// calculate the number of episodes remaining in the LHS and RHS episode pickers
const lhsRemaining = selectEpisode1.selectedIndex;
const rhsRemaining = selectEpisode2.length - selectEpisode2.selectedIndex - 1;
@ -1332,8 +1420,8 @@
populateLibraries();
selectAllLibrariesChanged();
autoSkipChanged();
autoSkipCreditsChanged();
persistSkipChanged();
generateAutoSkipTypeList();
generateAutoSkipClientList();
Dashboard.hideLoadingMsg();
@ -1372,10 +1460,18 @@
eraseTimestamps("Introduction");
e.preventDefault();
});
btnEraseRecapTimestamps.addEventListener("click", (e) => {
eraseTimestamps("Recap");
e.preventDefault();
});
btnEraseCreditTimestamps.addEventListener("click", (e) => {
eraseTimestamps("Credits");
e.preventDefault();
});
btnErasePreviewTimestamps.addEventListener("click", (e) => {
eraseTimestamps("Preview");
e.preventDefault();
});
btnSeasonEraseTimestamps.addEventListener("click", () => {
Dashboard.confirm("Are you sure you want to erase all timestamps for this season?", "Confirm timestamp erasure", (result) => {
if (!result) {
@ -1418,6 +1514,8 @@
analyzerActions: {
Introduction: actionIntro.value,
Credits: actionCredits.value,
Recap: actionRecap.value,
Preview: actionPreview.value,
},
};
@ -1432,25 +1530,49 @@
const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value;
const newLhs = {
Introduction: {
ItemId: lhsId,
Start: getEditValue("editLeftIntroEpisodeStartEdit"),
End: getEditValue("editLeftIntroEpisodeEndEdit"),
},
Credits: {
ItemId: lhsId,
Start: getEditValue("editLeftCreditEpisodeStartEdit"),
End: getEditValue("editLeftCreditEpisodeEndEdit"),
},
Recap: {
ItemId: lhsId,
Start: getEditValue("editLeftRecapEpisodeStartEdit"),
End: getEditValue("editLeftRecapEpisodeEndEdit"),
},
Preview: {
ItemId: lhsId,
Start: getEditValue("editLeftPreviewEpisodeStartEdit"),
End: getEditValue("editLeftPreviewEpisodeEndEdit"),
},
};
const rhsId = selectEpisode2.options[selectEpisode2.selectedIndex].value;
const newRhs = {
Introduction: {
ItemId: rhsId,
Start: getEditValue("editRightIntroEpisodeStartEdit"),
End: getEditValue("editRightIntroEpisodeEndEdit"),
},
Credits: {
ItemId: rhsId,
Start: getEditValue("editRightCreditEpisodeStartEdit"),
End: getEditValue("editRightCreditEpisodeEndEdit"),
},
Recap: {
ItemId: rhsId,
Start: getEditValue("editRightRecapEpisodeStartEdit"),
End: getEditValue("editRightRecapEpisodeEndEdit"),
},
Preview: {
ItemId: rhsId,
Start: getEditValue("editRightPreviewEpisodeStartEdit"),
End: getEditValue("editRightPreviewEpisodeEndEdit"),
},
};
fetchWithAuth("Episode/" + lhsId + "/Timestamps", "POST", JSON.stringify(newLhs));

View File

@ -233,7 +233,8 @@ const introSkipper = {
position > segment.IntroStart &&
position < segment.IntroEnd - 3)
) {
return { ...segment, SegmentType: key };
segment["SegmentType"] = key;
return segment;
}
}
return { SegmentType: "None" };

View File

@ -73,16 +73,25 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
return NotFound();
}
if (timestamps?.Introduction.End > 0.0)
if (timestamps == null)
{
var seg = new Segment(id, new TimeRange(timestamps.Introduction.Start, timestamps.Introduction.End));
await Plugin.Instance!.UpdateTimestamps([seg], AnalysisMode.Introduction).ConfigureAwait(false);
return NoContent();
}
if (timestamps?.Credits.End > 0.0)
var segmentTypes = new[]
{
var seg = new Segment(id, new TimeRange(timestamps.Credits.Start, timestamps.Credits.End));
await Plugin.Instance!.UpdateTimestamps([seg], AnalysisMode.Credits).ConfigureAwait(false);
(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)
@ -118,7 +127,7 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
}
var times = new TimeStamps();
var segments = Plugin.Instance!.GetSegmentsById(id);
var segments = Plugin.Instance!.GetTimestamps(id);
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
{
@ -130,6 +139,16 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
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;
}
@ -143,27 +162,30 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
public ActionResult<Dictionary<AnalysisMode, Intro>> GetSkippableSegments([FromRoute] Guid id)
{
var segments = GetIntros(id);
var result = new Dictionary<AnalysisMode, Intro>();
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
{
segments[AnalysisMode.Introduction] = introSegment;
result[AnalysisMode.Introduction] = introSegment;
}
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment))
{
segments[AnalysisMode.Credits] = creditSegment;
result[AnalysisMode.Credits] = creditSegment;
}
return segments;
return result;
}
/// <summary>Lookup and return the skippable timestamps for the provided item.</summary>
/// <param name="id">Unique identifier of this episode.</param>
/// <returns>Intro object if the provided item has an intro, null otherwise.</returns>
private static Dictionary<AnalysisMode, Intro> GetIntros(Guid id)
internal static Dictionary<AnalysisMode, Intro> GetIntros(Guid id)
{
var timestamps = Plugin.Instance!.GetSegmentsById(id);
var timestamps = Plugin.Instance!.GetTimestamps(id);
var intros = new Dictionary<AnalysisMode, Intro>();
var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds;
var config = Plugin.Instance.Configuration;
foreach (var (mode, timestamp) in timestamps)
{
@ -174,11 +196,10 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
// Create new Intro to avoid mutating the original stored in dictionary
var segment = new Intro(timestamp);
var config = Plugin.Instance.Configuration;
// Calculate intro end time based on mode
segment.IntroEnd = mode == AnalysisMode.Credits
? GetAdjustedIntroEnd(id, segment.IntroEnd, config)
// Calculate intro end time
segment.IntroEnd = runTime > 0 && runTime < segment.IntroEnd + 1
? runTime
: segment.IntroEnd - config.RemainingSecondsOfIntro;
// Set skip button prompt visibility times
@ -202,14 +223,6 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
return intros;
}
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;
}
/// <summary>
/// Erases all previously discovered introduction timestamps.
/// </summary>
@ -230,9 +243,9 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
db.DbSegment.RemoveRange(segments);
await db.SaveChangesAsync().ConfigureAwait(false);
if (eraseCache)
if (eraseCache && mode is AnalysisMode.Introduction or AnalysisMode.Credits)
{
FFmpegWrapper.DeleteCacheFiles(mode);
await Task.Run(() => FFmpegWrapper.DeleteCacheFiles(mode)).ConfigureAwait(false);
}
return NoContent();
@ -254,6 +267,8 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
config.SkipButtonEndCreditsText,
config.AutoSkip,
config.AutoSkipCredits,
config.AutoSkipRecap,
config.AutoSkipPreview,
config.ClientList);
}
}

View File

@ -96,7 +96,13 @@ public class VisualizationController(ILogger<VisualizationController> logger, Me
return NotFound();
}
return Ok(Plugin.Instance!.GetAnalyzerAction(seasonId));
var analyzerActions = new Dictionary<AnalysisMode, AnalyzerAction>();
foreach (var mode in Enum.GetValues<AnalysisMode>())
{
analyzerActions[mode] = Plugin.Instance!.GetAnalyzerAction(seasonId, mode);
}
return Ok(analyzerActions);
}
/// <summary>
@ -175,17 +181,10 @@ public class VisualizationController(ILogger<VisualizationController> logger, Me
foreach (var episode in episodes)
{
cancellationToken.ThrowIfCancellationRequested();
var segments = Plugin.Instance!.GetSegmentsById(episode.EpisodeId);
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
{
db.DbSegment.Remove(new DbSegment(introSegment, AnalysisMode.Introduction));
}
var existingSegments = db.DbSegment.Where(s => s.ItemId == episode.EpisodeId);
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment))
{
db.DbSegment.Remove(new DbSegment(creditSegment, AnalysisMode.Credits));
}
db.DbSegment.RemoveRange(existingSegments);
if (eraseCache)
{
@ -193,6 +192,13 @@ public class VisualizationController(ILogger<VisualizationController> logger, Me
}
}
var seasonInfo = db.DbSeasonInfo.Where(s => s.SeasonId == seasonId);
foreach (var info in seasonInfo)
{
db.Entry(info).Property(s => s.EpisodeIds).CurrentValue = [];
}
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
if (Plugin.Instance.Configuration.UpdateMediaSegments)
@ -216,7 +222,7 @@ public class VisualizationController(ILogger<VisualizationController> logger, Me
[HttpPost("AnalyzerActions/UpdateSeason")]
public async Task<ActionResult> UpdateAnalyzerActions([FromBody] UpdateAnalyzerActionsRequest request)
{
await Plugin.Instance!.UpdateAnalyzerActionAsync(request.Id, request.AnalyzerActions).ConfigureAwait(false);
await Plugin.Instance!.SetAnalyzerActionAsync(request.Id, request.AnalyzerActions).ConfigureAwait(false);
return NoContent();
}

View File

@ -17,4 +17,14 @@ public enum AnalysisMode
/// Detect credits.
/// </summary>
Credits,
/// <summary>
/// Detect previews.
/// </summary>
Preview,
/// <summary>
/// Detect recaps.
/// </summary>
Recap,
}

View File

@ -2,7 +2,6 @@
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
namespace IntroSkipper.Data;
@ -11,8 +10,6 @@ namespace IntroSkipper.Data;
/// </summary>
public class QueuedEpisode
{
private readonly bool[] _isAnalyzed = new bool[2];
/// <summary>
/// Gets or sets the series name.
/// </summary>
@ -28,16 +25,16 @@ public class QueuedEpisode
/// </summary>
public Guid EpisodeId { get; set; }
/// <summary>
/// Gets or sets the season id.
/// </summary>
public Guid SeasonId { get; set; }
/// <summary>
/// Gets or sets the series id.
/// </summary>
public Guid SeriesId { get; set; }
/// <summary>
/// Gets a value indicating whether this media has been already analyzed.
/// </summary>
public IReadOnlyList<bool> IsAnalyzed => _isAnalyzed;
/// <summary>
/// Gets or sets the full path to episode.
/// </summary>
@ -58,6 +55,11 @@ public class QueuedEpisode
/// </summary>
public bool IsMovie { get; set; }
/// <summary>
/// Gets or sets a value indicating whether an episode has been analyzed.
/// </summary>
public bool IsAnalyzed { get; set; }
/// <summary>
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
/// </summary>
@ -72,24 +74,4 @@ public class QueuedEpisode
/// Gets or sets the total duration of this media file (in seconds).
/// </summary>
public int Duration { get; set; }
/// <summary>
/// Sets a value indicating whether this media has been already analyzed.
/// </summary>
/// <param name="mode">Analysis mode.</param>
/// <param name="value">Value to set.</param>
public void SetAnalyzed(AnalysisMode mode, bool value)
{
_isAnalyzed[(int)mode] = value;
}
/// <summary>
/// Sets a value indicating whether this media has been already analyzed.
/// </summary>
/// <param name="mode">Analysis mode.</param>
/// <returns>Value of the analyzed mode.</returns>
public bool GetAnalyzed(AnalysisMode mode)
{
return _isAnalyzed[(int)mode];
}
}

View File

@ -18,5 +18,15 @@ namespace IntroSkipper.Data
/// Gets or sets Credits.
/// </summary>
public Segment Credits { get; set; } = new Segment();
/// <summary>
/// Gets or sets Recap.
/// </summary>
public Segment Recap { get; set; } = new Segment();
/// <summary>
/// Gets or sets Preview.
/// </summary>
public Segment Preview { get; set; } = new Segment();
}
}

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using IntroSkipper.Data;
namespace IntroSkipper.Db;
@ -20,11 +21,13 @@ public class DbSeasonInfo
/// <param name="seasonId">Season ID.</param>
/// <param name="mode">Analysis mode.</param>
/// <param name="action">Analyzer action.</param>
public DbSeasonInfo(Guid seasonId, AnalysisMode mode, AnalyzerAction action)
/// <param name="episodeIds">Episode IDs.</param>
public DbSeasonInfo(Guid seasonId, AnalysisMode mode, AnalyzerAction action, IEnumerable<Guid>? episodeIds = null)
{
SeasonId = seasonId;
Type = mode;
Action = action;
EpisodeIds = episodeIds ?? [];
}
/// <summary>
@ -48,4 +51,9 @@ public class DbSeasonInfo
/// Gets the analyzer action.
/// </summary>
public AnalyzerAction Action { get; private set; }
/// <summary>
/// Gets the season number.
/// </summary>
public IEnumerable<Guid> EpisodeIds { get; private set; } = [];
}

View File

@ -17,14 +17,14 @@ public class DbSegment
/// <summary>
/// Initializes a new instance of the <see cref="DbSegment"/> class.
/// </summary>
/// <param name="segment">Segment.</param>
/// <param name="mode">Analysis mode.</param>
public DbSegment(Segment segment, AnalysisMode mode)
/// <param name="segment">The segment to initialize the instance with.</param>
/// <param name="type">The type of analysis that was used to determine this segment.</param>
public DbSegment(Segment segment, AnalysisMode type)
{
ItemId = segment.EpisodeId;
Start = segment.Start;
End = segment.End;
Type = mode;
Type = type;
}
/// <summary>
@ -35,22 +35,31 @@ public class DbSegment
}
/// <summary>
/// Gets the item ID.
/// Gets or sets the episode id.
/// </summary>
public Guid ItemId { get; private set; }
public Guid ItemId { get; set; }
/// <summary>
/// Gets the start time.
/// Gets or sets the start time.
/// </summary>
public double Start { get; private set; }
public double Start { get; set; }
/// <summary>
/// Gets the end time.
/// Gets or sets the end time.
/// </summary>
public double End { get; private set; }
public double End { get; set; }
/// <summary>
/// Gets the analysis mode.
/// Gets the type of analysis that was used to determine this segment.
/// </summary>
public AnalysisMode Type { get; private set; }
/// <summary>
/// Converts the instance to a <see cref="Segment"/> object.
/// </summary>
/// <returns>A <see cref="Segment"/> object.</returns>
internal Segment ToSegment()
{
return new Segment(ItemId, new TimeRange(Start, End));
}
}

View File

@ -2,7 +2,12 @@
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using IntroSkipper.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace IntroSkipper.Db;
@ -12,26 +17,48 @@ namespace IntroSkipper.Db;
/// <remarks>
/// Initializes a new instance of the <see cref="IntroSkipperDbContext"/> class.
/// </remarks>
/// <param name="dbPath">The path to the SQLite database file.</param>
public class IntroSkipperDbContext(string dbPath) : DbContext
public class IntroSkipperDbContext : DbContext
{
private readonly string _dbPath = dbPath ?? throw new ArgumentNullException(nameof(dbPath));
private readonly string _dbPath;
/// <summary>
/// Initializes a new instance of the <see cref="IntroSkipperDbContext"/> class.
/// </summary>
/// <param name="dbPath">The path to the SQLite database file.</param>
public IntroSkipperDbContext(string dbPath)
{
_dbPath = dbPath;
DbSegment = Set<DbSegment>();
DbSeasonInfo = Set<DbSeasonInfo>();
}
/// <summary>
/// Initializes a new instance of the <see cref="IntroSkipperDbContext"/> class.
/// </summary>
/// <param name="options">The options.</param>
public IntroSkipperDbContext(DbContextOptions<IntroSkipperDbContext> options) : base(options)
{
var folder = Environment.SpecialFolder.LocalApplicationData;
var path = Environment.GetFolderPath(folder);
_dbPath = System.IO.Path.Join(path, "introskipper.db");
DbSegment = Set<DbSegment>();
DbSeasonInfo = Set<DbSeasonInfo>();
}
/// <summary>
/// Gets or sets the <see cref="DbSet{TEntity}"/> containing the segments.
/// </summary>
public DbSet<DbSegment> DbSegment { get; set; } = null!;
public DbSet<DbSegment> DbSegment { get; set; }
/// <summary>
/// Gets or sets the <see cref="DbSet{TEntity}"/> containing the season information.
/// </summary>
public DbSet<DbSeasonInfo> DbSeasonInfo { get; set; } = null!;
public DbSet<DbSeasonInfo> DbSeasonInfo { get; set; }
/// <inheritdoc/>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite($"Data Source={_dbPath}")
.EnableSensitiveDataLogging(false);
optionsBuilder.UseSqlite($"Data Source={_dbPath}");
}
/// <inheritdoc/>
@ -42,15 +69,15 @@ public class IntroSkipperDbContext(string dbPath) : DbContext
entity.ToTable("DbSegment");
entity.HasKey(s => new { s.ItemId, s.Type });
entity.Property(e => e.ItemId)
entity.HasIndex(e => e.ItemId);
entity.Property(e => e.Start)
.HasDefaultValue(0.0)
.IsRequired();
entity.Property(e => e.Type)
entity.Property(e => e.End)
.HasDefaultValue(0.0)
.IsRequired();
entity.Property(e => e.Start);
entity.Property(e => e.End);
});
modelBuilder.Entity<DbSeasonInfo>(entity =>
@ -58,13 +85,20 @@ public class IntroSkipperDbContext(string dbPath) : DbContext
entity.ToTable("DbSeasonInfo");
entity.HasKey(s => new { s.SeasonId, s.Type });
entity.Property(e => e.SeasonId)
entity.HasIndex(e => e.SeasonId);
entity.Property(e => e.Action)
.HasDefaultValue(AnalyzerAction.Default)
.IsRequired();
entity.Property(e => e.Type)
.IsRequired();
entity.Property(e => e.Action);
entity.Property(e => e.EpisodeIds)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<IEnumerable<Guid>>(v, (JsonSerializerOptions?)null) ?? new List<Guid>(),
new ValueComparer<IEnumerable<Guid>>(
(c1, c2) => (c1 ?? new List<Guid>()).SequenceEqual(c2 ?? new List<Guid>()),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()));
});
base.OnModelCreating(modelBuilder);
@ -74,7 +108,40 @@ public class IntroSkipperDbContext(string dbPath) : DbContext
/// Applies any pending migrations to the database.
/// </summary>
public void ApplyMigrations()
{
// If migrations table exists, just apply pending migrations normally
if (Database.GetAppliedMigrations().Any() || !Database.CanConnect())
{
Database.Migrate();
return;
}
// For databases without migration history
try
{
// Backup existing data
List<DbSegment> segments;
using (var db = new IntroSkipperDbContext(_dbPath))
{
segments = [.. db.DbSegment.AsEnumerable().Where(s => s.ToSegment().Valid)];
}
// Delete old database
Database.EnsureDeleted();
// Create new database with proper migration history
Database.Migrate();
// Restore the data
using (var db = new IntroSkipperDbContext(_dbPath))
{
db.DbSegment.AddRange(segments);
db.SaveChanges();
}
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to apply migrations", ex);
}
}
}

View File

@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace IntroSkipper.Db;
/// <summary>
/// IntroSkipperDbContext factory.
/// </summary>
public class IntroSkipperDbContextFactory : IDesignTimeDbContextFactory<IntroSkipperDbContext>
{
/// <inheritdoc/>
public IntroSkipperDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<IntroSkipperDbContext>();
optionsBuilder.UseSqlite("Data Source=introskipper.db")
.EnableSensitiveDataLogging(false);
return new IntroSkipperDbContext(optionsBuilder.Options);
}
}

View File

@ -36,8 +36,6 @@ public static partial class FFmpegWrapper
private static Dictionary<string, string> ChromaprintLogs { get; set; } = [];
private static ConcurrentDictionary<(Guid Id, AnalysisMode Mode), Dictionary<uint, int>> InvertedIndexCache { get; set; } = new();
/// <summary>
/// Check that the installed version of ffmpeg supports chromaprint.
/// </summary>
@ -134,36 +132,6 @@ public static partial class FFmpegWrapper
return Fingerprint(episode, mode, start, end);
}
/// <summary>
/// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at.
/// </summary>
/// <param name="id">Episode ID.</param>
/// <param name="fingerprint">Chromaprint fingerprint.</param>
/// <param name="mode">Mode.</param>
/// <returns>Inverted index.</returns>
public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint, AnalysisMode mode)
{
if (InvertedIndexCache.TryGetValue((id, mode), out var cached))
{
return cached;
}
var invIndex = new Dictionary<uint, int>();
for (int i = 0; i < fingerprint.Length; i++)
{
// Get the current point.
var point = fingerprint[i];
// Append the current sample's timecode to the collection for this point.
invIndex[point] = i;
}
InvertedIndexCache[(id, mode)] = invIndex;
return invIndex;
}
/// <summary>
/// Detect ranges of silence in the provided episode.
/// </summary>

View File

@ -13,7 +13,8 @@
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.10.*-*" />
<PackageReference Include="Jellyfin.Model" Version="10.10.*-*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.*-*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.556" PrivateAssets="All" />

View File

@ -152,10 +152,13 @@ namespace IntroSkipper.Manager
{
QueueEpisode(episode);
}
else if (_analyzeMovies && item is Movie movie)
else if (item is Movie movie)
{
if (_analyzeMovies)
{
QueueMovie(movie);
}
}
else
{
_logger.LogDebug("Item {Name} is not an episode or movie", item.Name);
@ -219,6 +222,7 @@ namespace IntroSkipper.Manager
SeriesName = episode.SeriesName,
SeasonNumber = episode.AiredSeasonNumber ?? 0,
SeriesId = episode.SeriesId,
SeasonId = episode.SeasonId,
EpisodeId = episode.Id,
Name = episode.Name,
IsAnime = isAnime,
@ -253,10 +257,12 @@ namespace IntroSkipper.Manager
{
SeriesName = movie.Name,
SeriesId = movie.Id,
SeasonId = movie.Id,
EpisodeId = movie.Id,
Name = movie.Name,
Path = movie.Path,
Duration = Convert.ToInt32(duration),
CreditsFingerprintStart = Convert.ToInt32(duration - pluginInstance.Configuration.MaximumMovieCreditsDuration),
IsMovie = true
});
@ -288,11 +294,13 @@ namespace IntroSkipper.Manager
/// <param name="candidates">Queued media items.</param>
/// <param name="modes">Analysis mode.</param>
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
public (IReadOnlyList<QueuedEpisode> VerifiedItems, IReadOnlyCollection<AnalysisMode> RequiredModes)
internal (IReadOnlyList<QueuedEpisode> QueuedEpisodes, IReadOnlyCollection<AnalysisMode> RequiredModes)
VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<AnalysisMode> modes)
{
var verified = new List<QueuedEpisode>();
var reqModes = new HashSet<AnalysisMode>();
var requiredModes = new HashSet<AnalysisMode>();
var episodeIds = Plugin.Instance!.GetEpisodeIds(candidates[0].SeasonId);
foreach (var candidate in candidates)
{
@ -305,20 +313,12 @@ namespace IntroSkipper.Manager
}
verified.Add(candidate);
var segments = Plugin.Instance!.GetSegmentsById(candidate.EpisodeId);
foreach (var mode in modes)
{
if (segments.TryGetValue(mode, out var segment))
if (!episodeIds.TryGetValue(mode, out var ids) || !ids.Contains(candidate.EpisodeId) || Plugin.Instance!.AnalyzeAgain)
{
if (segment.Valid)
{
candidate.SetAnalyzed(mode, true);
}
}
else
{
reqModes.Add(mode);
requiredModes.Add(mode);
}
}
}
@ -332,7 +332,7 @@ namespace IntroSkipper.Manager
}
}
return (verified, reqModes);
return (verified, requiredModes);
}
}
}

View File

@ -0,0 +1,73 @@
// <auto-generated />
using System;
using IntroSkipper.Db;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IntroSkipper.Migrations
{
[DbContext(typeof(IntroSkipperDbContext))]
[Migration("20241116153434_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
modelBuilder.Entity("IntroSkipper.Db.DbSeasonInfo", b =>
{
b.Property<Guid>("SeasonId")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<int>("Action")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("EpisodeIds")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("SeasonId", "Type");
b.HasIndex("SeasonId");
b.ToTable("DbSeasonInfo", (string)null);
});
modelBuilder.Entity("IntroSkipper.Db.DbSegment", b =>
{
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<double>("End")
.ValueGeneratedOnAdd()
.HasColumnType("REAL")
.HasDefaultValue(0.0);
b.Property<double>("Start")
.ValueGeneratedOnAdd()
.HasColumnType("REAL")
.HasDefaultValue(0.0);
b.HasKey("ItemId", "Type");
b.HasIndex("ItemId");
b.ToTable("DbSegment", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IntroSkipper.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DbSeasonInfo",
columns: table => new
{
SeasonId = table.Column<Guid>(type: "TEXT", nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
Action = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
EpisodeIds = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DbSeasonInfo", x => new { x.SeasonId, x.Type });
});
migrationBuilder.CreateTable(
name: "DbSegment",
columns: table => new
{
ItemId = table.Column<Guid>(type: "TEXT", nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
Start = table.Column<double>(type: "REAL", nullable: false, defaultValue: 0.0),
End = table.Column<double>(type: "REAL", nullable: false, defaultValue: 0.0)
},
constraints: table =>
{
table.PrimaryKey("PK_DbSegment", x => new { x.ItemId, x.Type });
});
migrationBuilder.CreateIndex(
name: "IX_DbSeasonInfo_SeasonId",
table: "DbSeasonInfo",
column: "SeasonId");
migrationBuilder.CreateIndex(
name: "IX_DbSegment_ItemId",
table: "DbSegment",
column: "ItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DbSeasonInfo");
migrationBuilder.DropTable(
name: "DbSegment");
}
}
}

View File

@ -0,0 +1,70 @@
// <auto-generated />
using System;
using IntroSkipper.Db;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace IntroSkipper.Migrations
{
[DbContext(typeof(IntroSkipperDbContext))]
partial class IntroSkipperDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
modelBuilder.Entity("IntroSkipper.Db.DbSeasonInfo", b =>
{
b.Property<Guid>("SeasonId")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<int>("Action")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("EpisodeIds")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("SeasonId", "Type");
b.HasIndex("SeasonId");
b.ToTable("DbSeasonInfo", (string)null);
});
modelBuilder.Entity("IntroSkipper.Db.DbSegment", b =>
{
b.Property<Guid>("ItemId")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<double>("End")
.ValueGeneratedOnAdd()
.HasColumnType("REAL")
.HasDefaultValue(0.0);
b.Property<double>("Start")
.ValueGeneratedOnAdd()
.HasColumnType("REAL")
.HasDefaultValue(0.0);
b.HasKey("ItemId", "Type");
b.HasIndex("ItemId");
b.ToTable("DbSegment", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@ -14,7 +14,6 @@ using IntroSkipper.Configuration;
using IntroSkipper.Data;
using IntroSkipper.Db;
using IntroSkipper.Helper;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Configuration;
@ -37,7 +36,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
private readonly ILibraryManager _libraryManager;
private readonly IItemRepository _itemRepository;
private readonly IApplicationHost _applicationHost;
private readonly ILogger<Plugin> _logger;
private readonly string _introPath;
private readonly string _creditsPath;
@ -46,7 +44,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationHost">Application host.</param>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
/// <param name="serverConfiguration">Server configuration manager.</param>
@ -54,7 +51,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <param name="itemRepository">Item repository.</param>
/// <param name="logger">Logger.</param>
public Plugin(
IApplicationHost applicationHost,
IApplicationPaths applicationPaths,
IXmlSerializer xmlSerializer,
IServerConfigurationManager serverConfiguration,
@ -65,7 +61,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
Instance = this;
_applicationHost = applicationHost;
_libraryManager = libraryManager;
_itemRepository = itemRepository;
_logger = logger;
@ -128,11 +123,10 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
try
{
using var db = new IntroSkipperDbContext(_dbPath);
db.Database.EnsureCreated();
db.ApplyMigrations();
if (File.Exists(_introPath) || File.Exists(_creditsPath))
{
RestoreTimestampsAsync(db).GetAwaiter().GetResult();
RestoreTimestamps();
}
}
catch (Exception ex)
@ -160,6 +154,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary>
public string DbPath => _dbPath;
/// <summary>
/// Gets or sets a value indicating whether to analyze again.
/// </summary>
public bool AnalyzeAgain { get; set; }
/// <summary>
/// Gets the most recent media item queue.
/// </summary>
@ -199,18 +198,16 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <summary>
/// Restore previous analysis results from disk.
/// </summary>
/// <param name="db">IntroSkipperDbContext.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task RestoreTimestampsAsync(IntroSkipperDbContext db)
public void RestoreTimestamps()
{
using var db = new IntroSkipperDbContext(_dbPath);
// Import intros
if (File.Exists(_introPath))
{
var introList = XmlSerializationHelper.DeserializeFromXml<Segment>(_introPath);
foreach (var intro in introList)
{
var dbSegment = new DbSegment(intro, AnalysisMode.Introduction);
db.DbSegment.Add(dbSegment);
db.DbSegment.Add(new DbSegment(intro, AnalysisMode.Introduction));
}
}
@ -220,12 +217,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
var creditList = XmlSerializationHelper.DeserializeFromXml<Segment>(_creditsPath);
foreach (var credit in creditList)
{
var dbSegment = new DbSegment(credit, AnalysisMode.Credits);
db.DbSegment.Add(dbSegment);
db.DbSegment.Add(new DbSegment(credit, AnalysisMode.Credits));
}
}
await db.SaveChangesAsync().ConfigureAwait(false);
db.SaveChanges();
File.Delete(_introPath);
File.Delete(_creditsPath);
@ -259,7 +255,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
return id != Guid.Empty ? _libraryManager.GetItemById(id) : null;
}
internal IReadOnlyList<Folder> GetCollectionFolders(Guid id)
internal ICollection<Folder> GetCollectionFolders(Guid id)
{
var item = GetItem(id);
return item is not null ? _libraryManager.GetCollectionFolders(item) : [];
@ -301,48 +297,43 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
return _itemRepository.GetChapters(item);
}
internal async Task UpdateTimestamps(IReadOnlyList<Segment> newTimestamps, AnalysisMode mode)
internal async Task UpdateTimestampAsync(Segment segment, AnalysisMode mode)
{
if (newTimestamps.Count == 0)
{
return;
}
_logger.LogDebug("Starting UpdateTimestamps with {Count} segments for mode {Mode}", newTimestamps.Count, mode);
using var db = new IntroSkipperDbContext(_dbPath);
var segments = newTimestamps.Select(s => new DbSegment(s, mode)).ToList();
var newItemIds = segments.Select(s => s.ItemId).ToHashSet();
var existingIds = db.DbSegment
.Where(s => s.Type == mode && newItemIds.Contains(s.ItemId))
.Select(s => s.ItemId)
.ToHashSet();
foreach (var segment in segments)
try
{
if (existingIds.Contains(segment.ItemId))
var existing = await db.DbSegment
.FirstOrDefaultAsync(s => s.ItemId == segment.EpisodeId && s.Type == mode)
.ConfigureAwait(false);
var dbSegment = new DbSegment(segment, mode);
if (existing is not null)
{
db.DbSegment.Update(segment);
db.Entry(existing).CurrentValues.SetValues(dbSegment);
}
else
{
db.DbSegment.Add(segment);
}
db.DbSegment.Add(dbSegment);
}
await db.SaveChangesAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update timestamp for episode {EpisodeId}", segment.EpisodeId);
throw;
}
}
internal async Task ClearInvalidSegments()
internal IReadOnlyDictionary<AnalysisMode, Segment> GetTimestamps(Guid id)
{
using var db = new IntroSkipperDbContext(_dbPath);
db.DbSegment.RemoveRange(db.DbSegment.Where(s => s.End == 0));
await db.SaveChangesAsync().ConfigureAwait(false);
return db.DbSegment.Where(s => s.ItemId == id)
.ToDictionary(s => s.Type, s => s.ToSegment());
}
internal async Task CleanTimestamps(HashSet<Guid> episodeIds)
internal async Task CleanTimestamps(IEnumerable<Guid> episodeIds)
{
using var db = new IntroSkipperDbContext(_dbPath);
db.DbSegment.RemoveRange(db.DbSegment
@ -350,35 +341,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
await db.SaveChangesAsync().ConfigureAwait(false);
}
internal IReadOnlyDictionary<AnalysisMode, Segment> GetSegmentsById(Guid id)
{
using var db = new IntroSkipperDbContext(_dbPath);
return db.DbSegment
.Where(s => s.ItemId == id)
.ToDictionary(
s => s.Type,
s => new Segment
{
EpisodeId = s.ItemId,
Start = s.Start,
End = s.End
});
}
internal Segment GetSegmentByMode(Guid id, AnalysisMode mode)
{
using var db = new IntroSkipperDbContext(_dbPath);
return db.DbSegment
.Where(s => s.ItemId == id && s.Type == mode)
.Select(s => new Segment
{
EpisodeId = s.ItemId,
Start = s.Start,
End = s.End
}).FirstOrDefault() ?? new Segment(id);
}
internal async Task UpdateAnalyzerActionAsync(Guid id, IReadOnlyDictionary<AnalysisMode, AnalyzerAction> analyzerActions)
internal async Task SetAnalyzerActionAsync(Guid id, IReadOnlyDictionary<AnalysisMode, AnalyzerAction> analyzerActions)
{
using var db = new IntroSkipperDbContext(_dbPath);
var existingEntries = await db.DbSeasonInfo
@ -388,24 +351,42 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
foreach (var (mode, action) in analyzerActions)
{
var dbSeasonInfo = new DbSeasonInfo(id, mode, action);
if (existingEntries.TryGetValue(mode, out var existing))
{
db.Entry(existing).CurrentValues.SetValues(dbSeasonInfo);
db.Entry(existing).Property(s => s.Action).CurrentValue = action;
}
else
{
db.DbSeasonInfo.Add(dbSeasonInfo);
db.DbSeasonInfo.Add(new DbSeasonInfo(id, mode, action));
}
}
await db.SaveChangesAsync().ConfigureAwait(false);
}
internal IReadOnlyDictionary<AnalysisMode, AnalyzerAction> GetAnalyzerAction(Guid id)
internal async Task SetEpisodeIdsAsync(Guid id, AnalysisMode mode, IEnumerable<Guid> episodeIds)
{
using var db = new IntroSkipperDbContext(_dbPath);
return db.DbSeasonInfo.Where(s => s.SeasonId == id).ToDictionary(s => s.Type, s => s.Action);
var seasonInfo = db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode);
if (seasonInfo is null)
{
seasonInfo = new DbSeasonInfo(id, mode, AnalyzerAction.Default, episodeIds);
db.DbSeasonInfo.Add(seasonInfo);
}
else
{
db.Entry(seasonInfo).Property(s => s.EpisodeIds).CurrentValue = episodeIds;
}
await db.SaveChangesAsync().ConfigureAwait(false);
}
internal IReadOnlyDictionary<AnalysisMode, IEnumerable<Guid>> GetEpisodeIds(Guid id)
{
using var db = new IntroSkipperDbContext(_dbPath);
return db.DbSeasonInfo.Where(s => s.SeasonId == id)
.ToDictionary(s => s.Type, s => s.EpisodeIds);
}
internal AnalyzerAction GetAnalyzerAction(Guid id, AnalysisMode mode)
@ -414,11 +395,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
return db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode)?.Action ?? AnalyzerAction.Default;
}
internal async Task CleanSeasonInfoAsync()
internal async Task CleanSeasonInfoAsync(IEnumerable<Guid> ids)
{
using var db = new IntroSkipperDbContext(_dbPath);
var obsoleteSeasons = await db.DbSeasonInfo
.Where(s => !Instance!.QueuedMediaItems.Keys.Contains(s.SeasonId))
.Where(s => !ids.Contains(s.SeasonId))
.ToListAsync().ConfigureAwait(false);
db.DbSeasonInfo.RemoveRange(obsoleteSeasons);
await db.SaveChangesAsync().ConfigureAwait(false);

View File

@ -19,7 +19,6 @@ namespace IntroSkipper
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddHostedService<AutoSkip>();
serviceCollection.AddHostedService<AutoSkipCredits>();
serviceCollection.AddHostedService<Entrypoint>();
serviceCollection.AddSingleton<IMediaSegmentProvider, SegmentProvider>();
serviceCollection.AddSingleton<MediaSegmentUpdateManager>();

View File

@ -32,40 +32,58 @@ namespace IntroSkipper.Providers
var segments = new List<MediaSegmentDto>();
var remainingTicks = Plugin.Instance.Configuration.RemainingSecondsOfIntro * TimeSpan.TicksPerSecond;
var itemSegments = Plugin.Instance.GetSegmentsById(request.ItemId);
var itemSegments = Plugin.Instance.GetTimestamps(request.ItemId);
var runTimeTicks = Plugin.Instance.GetItem(request.ItemId)?.RunTimeTicks ?? 0;
// Add intro segment if found
if (itemSegments.TryGetValue(AnalysisMode.Introduction, out var introSegment) && introSegment.Valid)
// Define mappings between AnalysisMode and MediaSegmentType
var segmentMappings = new List<(AnalysisMode Mode, MediaSegmentType Type)>
{
(AnalysisMode.Introduction, MediaSegmentType.Intro),
(AnalysisMode.Recap, MediaSegmentType.Recap),
(AnalysisMode.Preview, MediaSegmentType.Preview),
(AnalysisMode.Credits, MediaSegmentType.Outro)
};
foreach (var (mode, type) in segmentMappings)
{
if (itemSegments.TryGetValue(mode, out var segment) && segment.Valid)
{
long startTicks = (long)(segment.Start * TimeSpan.TicksPerSecond);
long endTicks = CalculateEndTicks(mode, segment, runTimeTicks, remainingTicks);
segments.Add(new MediaSegmentDto
{
StartTicks = (long)(introSegment.Start * TimeSpan.TicksPerSecond),
EndTicks = (long)(introSegment.End * TimeSpan.TicksPerSecond) - remainingTicks,
StartTicks = startTicks,
EndTicks = endTicks,
ItemId = request.ItemId,
Type = MediaSegmentType.Intro
Type = type
});
}
// Add outro/credits segment if found
if (itemSegments.TryGetValue(AnalysisMode.Credits, out var creditSegment) && creditSegment.Valid)
{
var creditEndTicks = (long)(creditSegment.End * TimeSpan.TicksPerSecond);
var runTimeTicks = Plugin.Instance.GetItem(request.ItemId)?.RunTimeTicks ?? long.MaxValue;
segments.Add(new MediaSegmentDto
{
StartTicks = (long)(creditSegment.Start * TimeSpan.TicksPerSecond),
EndTicks = runTimeTicks > creditEndTicks + TimeSpan.TicksPerSecond
? creditEndTicks - remainingTicks
: runTimeTicks,
ItemId = request.ItemId,
Type = MediaSegmentType.Outro
});
}
return Task.FromResult<IReadOnlyList<MediaSegmentDto>>(segments);
}
/// <summary>
/// Calculates the end ticks based on the segment type and runtime.
/// </summary>
private static long CalculateEndTicks(AnalysisMode mode, Segment segment, long runTimeTicks, long remainingTicks)
{
long endTicks = (long)(segment.End * TimeSpan.TicksPerSecond);
if (mode is AnalysisMode.Preview or AnalysisMode.Credits)
{
if (runTimeTicks > 0 && runTimeTicks < endTicks + TimeSpan.TicksPerSecond)
{
return Math.Max(runTimeTicks, endTicks);
}
return endTicks - remainingTicks;
}
return endTicks - remainingTicks;
}
/// <inheritdoc/>
public ValueTask<bool> Supports(BaseItem item) => ValueTask.FromResult(item is Episode or Movie);
}

View File

@ -3,12 +3,13 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Analyzers;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using IntroSkipper.Db;
using IntroSkipper.Manager;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
@ -18,158 +19,136 @@ namespace IntroSkipper.ScheduledTasks;
/// <summary>
/// Common code shared by all media item analyzer tasks.
/// </summary>
public class BaseItemAnalyzerTask
{
private readonly IReadOnlyCollection<AnalysisMode> _analysisModes;
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager;
/// <summary>
/// <remarks>
/// Initializes a new instance of the <see cref="BaseItemAnalyzerTask"/> class.
/// </summary>
/// <param name="modes">Analysis mode.</param>
/// </remarks>
/// <param name="logger">Task logger.</param>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="mediaSegmentUpdateManager">MediaSegmentUpdateManager.</param>
public BaseItemAnalyzerTask(
IReadOnlyCollection<AnalysisMode> modes,
public class BaseItemAnalyzerTask(
ILogger logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager,
MediaSegmentUpdateManager mediaSegmentUpdateManager)
{
_analysisModes = modes;
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
_mediaSegmentUpdateManager = mediaSegmentUpdateManager;
}
private readonly ILogger _logger = logger;
private readonly ILoggerFactory _loggerFactory = loggerFactory;
private readonly ILibraryManager _libraryManager = libraryManager;
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <summary>
/// Analyze all media items on the server.
/// </summary>
/// <param name="progress">Progress.</param>
/// <param name="progress">Progress reporter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="seasonsToAnalyze">Season Ids to analyze.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task AnalyzeItems(
/// <param name="seasonsToAnalyze">Season IDs to analyze.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task AnalyzeItemsAsync(
IProgress<double> progress,
CancellationToken cancellationToken,
IReadOnlyCollection<Guid>? seasonsToAnalyze = null)
{
var ffmpegValid = FFmpegWrapper.CheckFFmpegVersion();
// Assert that ffmpeg with chromaprint is installed
if (Plugin.Instance!.Configuration.WithChromaprint && !ffmpegValid)
if (_config.WithChromaprint && !FFmpegWrapper.CheckFFmpegVersion())
{
throw new FingerprintException(
"Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg5. If Jellyfin is running in a container, upgrade to version 10.8.0 or newer.");
"Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg7. If Jellyfin is running in a container, upgrade to version 10.10.0 or newer.");
}
HashSet<AnalysisMode> modes = [
.. _config.ScanIntroduction ? [AnalysisMode.Introduction] : Array.Empty<AnalysisMode>(),
.. _config.ScanCredits ? [AnalysisMode.Credits] : Array.Empty<AnalysisMode>(),
.. _config.ScanRecap ? [AnalysisMode.Recap] : Array.Empty<AnalysisMode>(),
.. _config.ScanPreview ? [AnalysisMode.Preview] : Array.Empty<AnalysisMode>()
];
var queueManager = new QueueManager(
_loggerFactory.CreateLogger<QueueManager>(),
_libraryManager);
var queue = queueManager.GetMediaItems();
// Filter the queue based on seasonsToAnalyze
if (seasonsToAnalyze is { Count: > 0 })
if (seasonsToAnalyze?.Count > 0)
{
queue = queue.Where(kvp => seasonsToAnalyze.Contains(kvp.Key)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
queue = queue.Where(kvp => seasonsToAnalyze.Contains(kvp.Key))
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
}
int totalQueued = queue.Sum(kvp => kvp.Value.Count) * _analysisModes.Count;
int totalQueued = queue.Sum(kvp => kvp.Value.Count) * modes.Count;
if (totalQueued == 0)
{
throw new FingerprintException(
"No libraries selected for analysis. Please visit the plugin settings to configure.");
}
var totalProcessed = 0;
int totalProcessed = 0;
var options = new ParallelOptions
{
MaxDegreeOfParallelism = Plugin.Instance.Configuration.MaxParallelism,
MaxDegreeOfParallelism = Math.Max(1, _config.MaxParallelism),
CancellationToken = cancellationToken
};
await Parallel.ForEachAsync(queue, options, async (season, ct) =>
{
var updateManagers = false;
// Since the first run of the task can run for multiple hours, ensure that none
// of the current media items were deleted from Jellyfin since the task was started.
var (episodes, requiredModes) = queueManager.VerifyQueue(
season.Value,
_analysisModes);
var updateMediaSegments = false;
var (episodes, requiredModes) = queueManager.VerifyQueue(season.Value, modes);
if (episodes.Count == 0)
{
return;
}
var first = episodes[0];
if (requiredModes.Count == 0)
{
_logger.LogDebug(
"All episodes in {Name} season {Season} have already been analyzed",
first.SeriesName,
first.SeasonNumber);
Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly
progress.Report(totalProcessed * 100 / totalQueued);
}
else if (_analysisModes.Count != requiredModes.Count)
{
Interlocked.Add(ref totalProcessed, episodes.Count);
progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed
}
try
{
ct.ThrowIfCancellationRequested();
foreach (AnalysisMode mode in requiredModes)
var firstEpisode = episodes[0];
if (modes.Count != requiredModes.Count)
{
var action = Plugin.Instance!.GetAnalyzerAction(season.Key, mode);
var analyzed = await AnalyzeItems(episodes, mode, action, ct).ConfigureAwait(false);
Interlocked.Add(ref totalProcessed, episodes.Count * (modes.Count - requiredModes.Count));
progress.Report((double)totalProcessed / totalQueued * 100);
}
foreach (var mode in requiredModes)
{
ct.ThrowIfCancellationRequested();
int analyzed = await AnalyzeItemsAsync(
episodes,
mode,
ct).ConfigureAwait(false);
Interlocked.Add(ref totalProcessed, analyzed);
updateManagers = analyzed > 0 || updateManagers;
progress.Report(totalProcessed * 100 / totalQueued);
updateMediaSegments = analyzed > 0 || updateMediaSegments;
progress.Report((double)totalProcessed / totalQueued * 100);
}
}
catch (OperationCanceledException ex)
catch (OperationCanceledException)
{
_logger.LogDebug(ex, "Analysis cancelled");
_logger.LogInformation("Analysis was canceled.");
}
catch (FingerprintException ex)
{
_logger.LogWarning(
"Unable to analyze {Series} season {Season}: unable to fingerprint: {Ex}",
first.SeriesName,
first.SeasonNumber,
ex);
_logger.LogWarning(ex, "Fingerprint exception during analysis.");
}
catch (Exception ex)
{
_logger.LogError(ex, "An unexpected error occurred during analysis");
_logger.LogError(ex, "An unexpected error occurred during analysis.");
throw;
}
if (Plugin.Instance.Configuration.RebuildMediaSegments || (updateManagers && Plugin.Instance.Configuration.UpdateMediaSegments))
if (_config.RebuildMediaSegments || (updateMediaSegments && _config.UpdateMediaSegments))
{
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, ct).ConfigureAwait(false);
}
}).ConfigureAwait(false);
if (Plugin.Instance.Configuration.RebuildMediaSegments)
Plugin.Instance!.AnalyzeAgain = false;
if (_config.RebuildMediaSegments)
{
_logger.LogInformation("Turning Mediasegment");
Plugin.Instance.Configuration.RebuildMediaSegments = false;
Plugin.Instance.SaveConfiguration();
_logger.LogInformation("Regenerated media segments.");
_config.RebuildMediaSegments = false;
Plugin.Instance!.SaveConfiguration();
}
}
@ -178,24 +157,28 @@ public class BaseItemAnalyzerTask
/// </summary>
/// <param name="items">Media items to analyze.</param>
/// <param name="mode">Analysis mode.</param>
/// <param name="action">Analyzer action.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of items that were successfully analyzed.</returns>
private async Task<int> AnalyzeItems(
/// <returns>Number of items successfully analyzed.</returns>
private async Task<int> AnalyzeItemsAsync(
IReadOnlyList<QueuedEpisode> items,
AnalysisMode mode,
AnalyzerAction action,
CancellationToken cancellationToken)
{
var totalItems = items.Count(e => !e.GetAnalyzed(mode));
// Only analyze specials (season 0) if the user has opted in.
var first = items[0];
if (!first.IsMovie && first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
if (!first.IsMovie && first.SeasonNumber == 0 && !_config.AnalyzeSeasonZero)
{
return 0;
}
// Reset the IsAnalyzed flag for all items
foreach (var item in items)
{
item.IsAnalyzed = false;
}
// Get the analyzer action for the current mode
var action = Plugin.Instance!.GetAnalyzerAction(first.SeasonId, mode);
_logger.LogInformation(
"[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}",
mode,
@ -203,24 +186,30 @@ public class BaseItemAnalyzerTask
first.SeriesName,
first.SeasonNumber);
var analyzers = new Collection<IMediaFileAnalyzer>();
// Create a list of analyzers to use for the current mode
var analyzers = new List<IMediaFileAnalyzer>();
if (action is AnalyzerAction.Chapter or AnalyzerAction.Default)
{
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
}
if (first.IsAnime && !first.IsMovie && action is AnalyzerAction.Chromaprint or AnalyzerAction.Default)
if (first.IsAnime && _config.WithChromaprint &&
mode is not (AnalysisMode.Recap or AnalysisMode.Preview) &&
action is AnalyzerAction.Default or AnalyzerAction.Chromaprint)
{
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
}
if (mode is AnalysisMode.Credits && action is AnalyzerAction.BlackFrame or AnalyzerAction.Default)
if (mode is AnalysisMode.Credits &&
action is AnalyzerAction.Default or AnalyzerAction.BlackFrame)
{
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
}
if (!first.IsAnime && !first.IsMovie && action is AnalyzerAction.Chromaprint or AnalyzerAction.Default)
if (!first.IsAnime && !first.IsMovie &&
mode is not (AnalysisMode.Recap or AnalysisMode.Preview) &&
action is AnalyzerAction.Default or AnalyzerAction.Chromaprint)
{
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
}
@ -229,16 +218,13 @@ public class BaseItemAnalyzerTask
// analyzed items from the queue.
foreach (var analyzer in analyzers)
{
items = await analyzer.AnalyzeMediaFiles(items, mode, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
items = await analyzer.AnalyzeMediaFiles(items, mode, cancellationToken).ConfigureAwait(false);
}
// Add items without intros/credits to blacklist.
var blacklisted = new List<Segment>(items.Where(e => !e.GetAnalyzed(mode)).Select(e => new Segment(e.EpisodeId)));
_logger.LogDebug("Blacklisting {Count} items for mode {Mode}", blacklisted.Count, mode);
await Plugin.Instance!.UpdateTimestamps(blacklisted, mode).ConfigureAwait(false);
totalItems -= blacklisted.Count;
// Set the episode IDs for the analyzed items
await Plugin.Instance!.SetEpisodeIdsAsync(first.SeasonId, mode, items.Select(i => i.EpisodeId)).ConfigureAwait(false);
return totalItems;
return items.Where(i => i.IsAnalyzed).Count();
}
}

View File

@ -86,8 +86,6 @@ public class CleanCacheTask : IScheduledTask
.SelectMany(episodes => episodes.Select(e => e.EpisodeId))
.ToHashSet();
await Plugin.Instance!.ClearInvalidSegments().ConfigureAwait(false);
await Plugin.Instance!.CleanTimestamps(validEpisodeIds).ConfigureAwait(false);
// Identify invalid episode IDs
@ -105,7 +103,9 @@ public class CleanCacheTask : IScheduledTask
}
// Clean up Season information by removing items that are no longer exist.
await Plugin.Instance!.CleanSeasonInfoAsync().ConfigureAwait(false);
await Plugin.Instance!.CleanSeasonInfoAsync(queue.Keys).ConfigureAwait(false);
Plugin.Instance!.AnalyzeAgain = true;
progress.Report(100);
}

View File

@ -1,107 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Data;
using IntroSkipper.Manager;
using IntroSkipper.Services;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.ScheduledTasks;
/// <summary>
/// Analyze all television episodes for credits.
/// TODO: analyze all media files.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="DetectCreditsTask"/> class.
/// </remarks>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
/// <param name="mediaSegmentUpdateManager">MediaSegment Update Manager.</param>
public class DetectCreditsTask(
ILogger<DetectCreditsTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager,
MediaSegmentUpdateManager mediaSegmentUpdateManager) : IScheduledTask
{
private readonly ILogger<DetectCreditsTask> _logger = logger;
private readonly ILoggerFactory _loggerFactory = loggerFactory;
private readonly ILibraryManager _libraryManager = libraryManager;
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Credits";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes media to determine the timestamp and length of credits";
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectCredits";
/// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
/// </summary>
/// <param name="progress">Task progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task.</returns>
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
if (_libraryManager is null)
{
throw new InvalidOperationException("Library manager was null");
}
// abort automatic analyzer if running
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
{
_logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
await Entrypoint.CancelAutomaticTaskAsync(cancellationToken).ConfigureAwait(false);
}
using (await ScheduledTaskSemaphore.AcquireAsync(cancellationToken).ConfigureAwait(false))
{
_logger.LogInformation("Scheduled Task is starting");
var modes = new List<AnalysisMode> { AnalysisMode.Credits };
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
modes,
_loggerFactory.CreateLogger<DetectCreditsTask>(),
_loggerFactory,
_libraryManager,
_mediaSegmentUpdateManager);
await baseCreditAnalyzer.AnalyzeItems(progress, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return [];
}
}

View File

@ -1,106 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Data;
using IntroSkipper.Manager;
using IntroSkipper.Services;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.ScheduledTasks;
/// <summary>
/// Analyze all television episodes for introduction sequences.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="DetectIntrosTask"/> class.
/// </remarks>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
/// <param name="mediaSegmentUpdateManager">MediaSegment Update Manager.</param>
public class DetectIntrosTask(
ILogger<DetectIntrosTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager,
MediaSegmentUpdateManager mediaSegmentUpdateManager) : IScheduledTask
{
private readonly ILogger<DetectIntrosTask> _logger = logger;
private readonly ILoggerFactory _loggerFactory = loggerFactory;
private readonly ILibraryManager _libraryManager = libraryManager;
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Intros";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes media to determine the timestamp and length of intros.";
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectIntroductions";
/// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
/// </summary>
/// <param name="progress">Task progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task.</returns>
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
if (_libraryManager is null)
{
throw new InvalidOperationException("Library manager was null");
}
// abort automatic analyzer if running
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
{
_logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
await Entrypoint.CancelAutomaticTaskAsync(cancellationToken).ConfigureAwait(false);
}
using (await ScheduledTaskSemaphore.AcquireAsync(cancellationToken).ConfigureAwait(false))
{
_logger.LogInformation("Scheduled Task is starting");
var modes = new List<AnalysisMode> { AnalysisMode.Introduction };
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
modes,
_loggerFactory.CreateLogger<DetectIntrosTask>(),
_loggerFactory,
_libraryManager,
_mediaSegmentUpdateManager);
await baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return [];
}
}

View File

@ -5,7 +5,6 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Data;
using IntroSkipper.Manager;
using IntroSkipper.Services;
using MediaBrowser.Controller.Library;
@ -15,22 +14,22 @@ using Microsoft.Extensions.Logging;
namespace IntroSkipper.ScheduledTasks;
/// <summary>
/// Analyze all television episodes for introduction sequences.
/// Analyze all television episodes for media segments.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="DetectIntrosCreditsTask"/> class.
/// Initializes a new instance of the <see cref="DetectSegmentsTask"/> class.
/// </remarks>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
/// <param name="mediaSegmentUpdateManager">MediaSegment Update Manager.</param>
public class DetectIntrosCreditsTask(
ILogger<DetectIntrosCreditsTask> logger,
public class DetectSegmentsTask(
ILogger<DetectSegmentsTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager,
MediaSegmentUpdateManager mediaSegmentUpdateManager) : IScheduledTask
{
private readonly ILogger<DetectIntrosCreditsTask> _logger = logger;
private readonly ILogger<DetectSegmentsTask> _logger = logger;
private readonly ILoggerFactory _loggerFactory = loggerFactory;
@ -41,7 +40,7 @@ public class DetectIntrosCreditsTask(
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Intros and Credits";
public string Name => "Detect and Analyze Media Segments";
/// <summary>
/// Gets the task category.
@ -56,7 +55,7 @@ public class DetectIntrosCreditsTask(
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectIntrosCredits";
public string Key => "IntroSkipperDetectSegmentsTask";
/// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
@ -74,7 +73,7 @@ public class DetectIntrosCreditsTask(
// abort automatic analyzer if running
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
{
_logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
_logger.LogInformation("Automatic Task is {TaskState} and will be canceled.", Entrypoint.AutomaticTaskState);
await Entrypoint.CancelAutomaticTaskAsync(cancellationToken).ConfigureAwait(false);
}
@ -82,16 +81,13 @@ public class DetectIntrosCreditsTask(
{
_logger.LogInformation("Scheduled Task is starting");
var modes = new List<AnalysisMode> { AnalysisMode.Introduction, AnalysisMode.Credits };
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
modes,
_loggerFactory.CreateLogger<DetectIntrosCreditsTask>(),
_loggerFactory.CreateLogger<DetectSegmentsTask>(),
_loggerFactory,
_libraryManager,
_mediaSegmentUpdateManager);
await baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken).ConfigureAwait(false);
await baseIntroAnalyzer.AnalyzeItemsAsync(progress, cancellationToken).ConfigureAwait(false);
}
}

View File

@ -2,15 +2,15 @@
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using IntroSkipper.Configuration;
using IntroSkipper.Controllers;
using IntroSkipper.Data;
using IntroSkipper.Db;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Entities;
@ -18,7 +18,6 @@ using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace IntroSkipper.Services
{
@ -32,124 +31,116 @@ namespace IntroSkipper.Services
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public class AutoSkip(
public sealed class AutoSkip(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkip> logger) : IHostedService, IDisposable
{
private readonly object _sentSeekCommandLock = new();
private ILogger<AutoSkip> _logger = logger;
private IUserDataManager _userDataManager = userDataManager;
private ISessionManager _sessionManager = sessionManager;
private Timer _playbackTimer = new(1000);
private Dictionary<string, bool> _sentSeekCommand = [];
private readonly IUserDataManager _userDataManager = userDataManager;
private readonly ISessionManager _sessionManager = sessionManager;
private readonly ILogger<AutoSkip> _logger = logger;
private readonly System.Timers.Timer _playbackTimer = new(1000);
private readonly ConcurrentDictionary<string, List<Intro>> _sentSeekCommand = [];
private PluginConfiguration _config = new();
private HashSet<string> _clientList = [];
private HashSet<AnalysisMode> _segmentTypes = [];
private bool _autoSkipEnabled;
private void AutoSkipChanged(object? sender, BasePluginConfiguration e)
{
var configuration = (PluginConfiguration)e;
_clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
var newState = configuration.AutoSkip || _clientList.Count > 0;
_logger.LogDebug("Setting playback timer enabled to {NewState}", newState);
_playbackTimer.Enabled = newState;
_config = (PluginConfiguration)e;
_clientList = [.. _config.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
_segmentTypes = [.. _config.TypeList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(Enum.Parse<AnalysisMode>)];
_autoSkipEnabled = (_config.AutoSkip || _clientList.Count > 0) && _segmentTypes.Count > 0;
_logger.LogDebug("Setting playback timer enabled to {AutoSkipEnabled}", _autoSkipEnabled);
_playbackTimer.Enabled = _autoSkipEnabled;
}
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
{
var itemId = e.Item.Id;
var newState = false;
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
// Ignore all events except playback start & end
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
if (e.SaveReason is not (UserDataSaveReason.PlaybackStart or UserDataSaveReason.PlaybackFinished) || !_autoSkipEnabled)
{
return;
}
// Lookup the session for this item.
SessionInfo? session = null;
var itemId = e.Item.Id;
var session = _sessionManager.Sessions
.FirstOrDefault(s => s.UserId == e.UserId && s.NowPlayingItem?.Id == itemId);
try
if (session is null)
{
foreach (var needle in _sessionManager.Sessions)
// Clean up orphaned sessions
if (!_sessionManager.Sessions
.Where(s => s.UserId == e.UserId && s.NowPlayingItem is null)
.Any(s => _sentSeekCommand.TryRemove(s.DeviceId, out _)))
{
if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
{
session = needle;
break;
}
_logger.LogInformation("Unable to find active session for item {ItemId}", itemId);
}
if (session == null)
{
_logger.LogInformation("Unable to find session for {Item}", itemId);
return;
}
}
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
{
return;
}
// If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session.
if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
{
newState = true;
}
// Reset the seek command state for this device.
lock (_sentSeekCommandLock)
{
var device = session.DeviceId;
_logger.LogDebug("Getting intros for session {Session}", device);
_logger.LogDebug("Resetting seek command state for session {Session}", device);
_sentSeekCommand[device] = newState;
}
bool firstEpisode = _config.SkipFirstEpisode && e.Item.IndexNumber.GetValueOrDefault(-1) == 1;
var intros = SkipIntroController.GetIntros(itemId)
.Where(i => _segmentTypes.Contains(i.Key) && (!firstEpisode || i.Key != AnalysisMode.Introduction))
.Select(i => i.Value)
.ToList();
_sentSeekCommand.AddOrUpdate(device, intros, (_, _) => intros);
}
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkip || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase)))
foreach (var session in _sessionManager.Sessions.Where(s => _config.AutoSkip || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase)))
{
var deviceId = session.DeviceId;
var itemId = session.NowPlayingItem.Id;
var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
// Don't send the seek command more than once in the same session.
lock (_sentSeekCommandLock)
{
if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent)
{
_logger.LogTrace("Already sent seek command for session {Session}", deviceId);
continue;
}
}
// Assert that an intro was detected for this item.
var intro = Plugin.Instance!.GetSegmentByMode(itemId, AnalysisMode.Introduction);
if (!intro.Valid)
if (!_sentSeekCommand.TryGetValue(deviceId, out var intros))
{
continue;
}
// Seek is unreliable if called at the very start of an episode.
var adjustedStart = Math.Max(1, intro.Start + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay);
var adjustedEnd = intro.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro;
var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
var currentIntro = intros.FirstOrDefault(i =>
position >= Math.Max(1, i.IntroStart + _config.SecondsOfIntroStartToPlay) &&
position < i.IntroEnd - 3.0); // 3 seconds before the end of the intro
if (currentIntro is null)
{
continue;
}
var introEnd = currentIntro.IntroEnd;
intros.Remove(currentIntro);
// Check if adjacent segment is within the maximum skip range.
var maxTimeSkip = _config.MaximumTimeSkip + _config.RemainingSecondsOfIntro;
var nextIntro = intros.FirstOrDefault(i => introEnd + maxTimeSkip >= i.IntroStart &&
introEnd < i.IntroEnd);
if (nextIntro is not null)
{
introEnd = nextIntro.IntroEnd;
intros.Remove(nextIntro);
}
_logger.LogDebug("Found segment for session {Session}, removing from list, {Intros} segments remaining", deviceId, intros.Count);
_logger.LogTrace(
"Playback position is {Position}, intro runs from {Start} to {End}",
position,
adjustedStart,
adjustedEnd);
if (position < adjustedStart || position > adjustedEnd)
{
continue;
}
"Playback position is {Position}",
position);
// Notify the user that an introduction is being skipped for them.
var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText;
var notificationText = _config.AutoSkipNotificationText;
if (!string.IsNullOrWhiteSpace(notificationText))
{
_sessionManager.SendMessageCommand(
@ -173,40 +164,20 @@ namespace IntroSkipper.Services
{
Command = PlaystateCommand.Seek,
ControllingUserId = session.UserId.ToString(),
SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond,
SeekPositionTicks = (long)introEnd * TimeSpan.TicksPerSecond,
},
CancellationToken.None);
// Flag that we've sent the seek command so that it's not sent repeatedly
lock (_sentSeekCommandLock)
{
_logger.LogTrace("Setting seek command state for session {Session}", deviceId);
_sentSeekCommand[deviceId] = true;
}
}
}
/// <summary>
/// Dispose.
/// Dispose resources.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="disposing">Dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
_playbackTimer.Stop();
_playbackTimer.Dispose();
}
@ -231,6 +202,8 @@ namespace IntroSkipper.Services
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
Plugin.Instance!.ConfigurationChanged -= AutoSkipChanged;
_playbackTimer.Stop();
return Task.CompletedTask;
}
}

View File

@ -1,236 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace IntroSkipper.Services
{
/// <summary>
/// Automatically skip past credit sequences.
/// Commands clients to seek to the end of the credits as soon as they start playing it.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="AutoSkipCredits"/> class.
/// </remarks>
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public class AutoSkipCredits(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkipCredits> logger) : IHostedService, IDisposable
{
private readonly object _sentSeekCommandLock = new();
private ILogger<AutoSkipCredits> _logger = logger;
private IUserDataManager _userDataManager = userDataManager;
private ISessionManager _sessionManager = sessionManager;
private Timer _playbackTimer = new(1000);
private Dictionary<string, bool> _sentSeekCommand = [];
private HashSet<string> _clientList = [];
private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e)
{
var configuration = (PluginConfiguration)e;
_clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
var newState = configuration.AutoSkipCredits || _clientList.Count > 0;
_logger.LogDebug("Setting playback timer enabled to {NewState}", newState);
_playbackTimer.Enabled = newState;
}
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
{
var itemId = e.Item.Id;
var newState = false;
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
// Ignore all events except playback start & end
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
{
return;
}
// Lookup the session for this item.
SessionInfo? session = null;
try
{
foreach (var needle in _sessionManager.Sessions)
{
if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
{
session = needle;
break;
}
}
if (session == null)
{
_logger.LogInformation("Unable to find session for {Item}", itemId);
return;
}
}
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
{
return;
}
// If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session.
if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
{
newState = true;
}
// Reset the seek command state for this device.
lock (_sentSeekCommandLock)
{
var device = session.DeviceId;
_logger.LogDebug("Resetting seek command state for session {Session}", device);
_sentSeekCommand[device] = newState;
}
}
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkipCredits || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase)))
{
var deviceId = session.DeviceId;
var itemId = session.NowPlayingItem.Id;
var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
// Don't send the seek command more than once in the same session.
lock (_sentSeekCommandLock)
{
if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent)
{
_logger.LogTrace("Already sent seek command for session {Session}", deviceId);
continue;
}
}
// Assert that credits were detected for this item.
var credit = Plugin.Instance!.GetSegmentByMode(itemId, AnalysisMode.Credits);
if (!credit.Valid)
{
continue;
}
// Seek is unreliable if called at the very end of an episode.
var adjustedStart = credit.Start + Plugin.Instance.Configuration.SecondsOfCreditsStartToPlay;
var adjustedEnd = credit.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro;
_logger.LogTrace(
"Playback position is {Position}, credits run from {Start} to {End}",
position,
adjustedStart,
adjustedEnd);
if (position < adjustedStart || position > adjustedEnd)
{
continue;
}
// Notify the user that credits are being skipped for them.
var notificationText = Plugin.Instance!.Configuration.AutoSkipCreditsNotificationText;
if (!string.IsNullOrWhiteSpace(notificationText))
{
_sessionManager.SendMessageCommand(
session.Id,
session.Id,
new MessageCommand
{
Header = string.Empty, // some clients require header to be a string instead of null
Text = notificationText,
TimeoutMs = 2000,
},
CancellationToken.None);
}
_logger.LogDebug("Sending seek command to {Session}", deviceId);
_sessionManager.SendPlaystateCommand(
session.Id,
session.Id,
new PlaystateRequest
{
Command = PlaystateCommand.Seek,
ControllingUserId = session.UserId.ToString(),
SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond,
},
CancellationToken.None);
// Flag that we've sent the seek command so that it's not sent repeatedly
lock (_sentSeekCommandLock)
{
_logger.LogTrace("Setting seek command state for session {Session}", deviceId);
_sentSeekCommand[deviceId] = true;
}
}
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="disposing">Dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
_playbackTimer.Stop();
_playbackTimer.Dispose();
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Setting up automatic credit skipping");
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged;
// Make the timer restart automatically and set enabled to match the configuration value.
_playbackTimer.AutoReset = true;
_playbackTimer.Elapsed += PlaybackTimer_Elapsed;
AutoSkipCreditChanged(null, Plugin.Instance.Configuration);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
return Task.CompletedTask;
}
}
}

View File

@ -6,7 +6,6 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using IntroSkipper.Manager;
using IntroSkipper.ScheduledTasks;
using MediaBrowser.Controller.Entities.Movies;
@ -112,7 +111,7 @@ namespace IntroSkipper.Services
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
private void OnItemChanged(object? sender, ItemChangeEventArgs itemChangeEventArgs)
{
if ((_config.AutoDetectIntros || _config.AutoDetectCredits) &&
if (_config.AutoDetectIntros &&
itemChangeEventArgs.Item is { LocationType: not LocationType.Virtual } item)
{
Guid? id = item is Episode episode ? episode.SeasonId : (item is Movie movie ? movie.Id : null);
@ -132,7 +131,7 @@ namespace IntroSkipper.Services
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs)
{
if ((_config.AutoDetectIntros || _config.AutoDetectCredits) &&
if (_config.AutoDetectIntros &&
eventArgs.Result is { Key: "RefreshLibrary", Status: TaskCompletionStatus.Completed } &&
AutomaticTaskState != TaskState.Running)
{
@ -143,7 +142,7 @@ namespace IntroSkipper.Services
private void OnSettingsChanged(object? sender, BasePluginConfiguration e)
{
_config = (PluginConfiguration)e;
_ = Plugin.Instance!.ClearInvalidSegments();
Plugin.Instance!.AnalyzeAgain = true;
}
/// <summary>
@ -196,20 +195,8 @@ namespace IntroSkipper.Services
_seasonsToAnalyze.Clear();
_analyzeAgain = false;
var modes = new List<AnalysisMode>();
if (_config.AutoDetectIntros)
{
modes.Add(AnalysisMode.Introduction);
}
if (_config.AutoDetectCredits)
{
modes.Add(AnalysisMode.Credits);
}
var analyzer = new BaseItemAnalyzerTask(modes, _loggerFactory.CreateLogger<Entrypoint>(), _loggerFactory, _libraryManager, _mediaSegmentUpdateManager);
await analyzer.AnalyzeItems(new Progress<double>(), _cancellationTokenSource.Token, seasonIds).ConfigureAwait(false);
var analyzer = new BaseItemAnalyzerTask(_loggerFactory.CreateLogger<Entrypoint>(), _loggerFactory, _libraryManager, _mediaSegmentUpdateManager);
await analyzer.AnalyzeItemsAsync(new Progress<double>(), _cancellationTokenSource.Token, seasonIds).ConfigureAwait(false);
if (_analyzeAgain && !_cancellationTokenSource.IsCancellationRequested)
{