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:
parent
6aa26fe9a7
commit
6ccf002e51
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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>
|
||||
|
@ -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));
|
||||
|
@ -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" };
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -17,4 +17,14 @@ public enum AnalysisMode
|
||||
/// Detect credits.
|
||||
/// </summary>
|
||||
Credits,
|
||||
|
||||
/// <summary>
|
||||
/// Detect previews.
|
||||
/// </summary>
|
||||
Preview,
|
||||
|
||||
/// <summary>
|
||||
/// Detect recaps.
|
||||
/// </summary>
|
||||
Recap,
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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; } = [];
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
20
IntroSkipper/Db/IntroSkipperDbContextFactory.cs
Normal file
20
IntroSkipper/Db/IntroSkipperDbContextFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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" />
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
73
IntroSkipper/Migrations/20241116153434_InitialCreate.Designer.cs
generated
Normal file
73
IntroSkipper/Migrations/20241116153434_InitialCreate.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
63
IntroSkipper/Migrations/20241116153434_InitialCreate.cs
Normal file
63
IntroSkipper/Migrations/20241116153434_InitialCreate.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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>();
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 [];
|
||||
}
|
||||
}
|
@ -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 [];
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
{
|
||||
|
Loading…
x
Reference in New Issue
Block a user