Revert "Use Jellyfins MediaSegmentType (#344)"

This reverts commit 29ee3e0bc861d128f4f691d7eb8d159da28eab43.
This commit is contained in:
rlauu 2024-10-16 16:05:59 +02:00
parent 29ee3e0bc8
commit ca9a167ad5
31 changed files with 1079 additions and 1050 deletions

View File

@ -6,7 +6,6 @@ using System;
using System.Collections.Generic;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Logging;
using Xunit;
@ -64,7 +63,7 @@ public class TestAudioFingerprinting
var actual = FFmpegWrapper.Fingerprint(
QueueEpisode("audio/big_buck_bunny_intro.mp3"),
MediaSegmentType.Intro);
AnalysisMode.Introduction);
Assert.Equal(expected, actual);
}
@ -84,7 +83,7 @@ public class TestAudioFingerprinting
{77, 5},
};
var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, MediaSegmentType.Intro);
var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction);
Assert.Equal(expected, actual);
}
@ -96,8 +95,8 @@ public class TestAudioFingerprinting
var lhsEpisode = QueueEpisode("audio/big_buck_bunny_intro.mp3");
var rhsEpisode = QueueEpisode("audio/big_buck_bunny_clip.mp3");
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, MediaSegmentType.Intro);
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, MediaSegmentType.Intro);
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction);
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction);
var (lhs, rhs) = chromaprint.CompareEpisodes(
lhsEpisode.EpisodeId,

View File

@ -5,7 +5,6 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
using Xunit;
@ -20,8 +19,8 @@ public class TestChapterAnalyzer
[InlineData("Introduction")]
public void TestIntroductionExpression(string chapterName)
{
var chapters = CreateChapters(chapterName, MediaSegmentType.Intro);
var introChapter = FindChapter(chapters, MediaSegmentType.Intro);
var chapters = CreateChapters(chapterName, AnalysisMode.Introduction);
var introChapter = FindChapter(chapters, AnalysisMode.Introduction);
Assert.NotNull(introChapter);
Assert.Equal(60, introChapter.Start);
@ -36,34 +35,34 @@ public class TestChapterAnalyzer
[InlineData("Credits")]
public void TestEndCreditsExpression(string chapterName)
{
var chapters = CreateChapters(chapterName, MediaSegmentType.Outro);
var creditsChapter = FindChapter(chapters, MediaSegmentType.Outro);
var chapters = CreateChapters(chapterName, AnalysisMode.Credits);
var creditsChapter = FindChapter(chapters, AnalysisMode.Credits);
Assert.NotNull(creditsChapter);
Assert.Equal(1890, creditsChapter.Start);
Assert.Equal(2000, creditsChapter.End);
}
private Segment? FindChapter(Collection<ChapterInfo> chapters, MediaSegmentType mode)
private Segment? FindChapter(Collection<ChapterInfo> chapters, AnalysisMode mode)
{
var logger = new LoggerFactory().CreateLogger<ChapterAnalyzer>();
var analyzer = new ChapterAnalyzer(logger);
var config = new Configuration.PluginConfiguration();
var expression = mode == MediaSegmentType.Intro ?
var expression = mode == AnalysisMode.Introduction ?
config.ChapterAnalyzerIntroductionPattern :
config.ChapterAnalyzerEndCreditsPattern;
return analyzer.FindMatchingChapter(new() { Duration = 2000 }, chapters, expression, mode);
}
private Collection<ChapterInfo> CreateChapters(string name, MediaSegmentType mode)
private Collection<ChapterInfo> CreateChapters(string name, AnalysisMode mode)
{
var chapters = new[]{
CreateChapter("Cold Open", 0),
CreateChapter(mode == MediaSegmentType.Intro ? name : "Introduction", 60),
CreateChapter(mode == AnalysisMode.Introduction ? name : "Introduction", 60),
CreateChapter("Main Episode", 90),
CreateChapter(mode == MediaSegmentType.Outro ? name : "Credits", 1890)
CreateChapter(mode == AnalysisMode.Credits ? name : "Credits", 1890)
};
return new(new List<ChapterInfo>(chapters));

View File

@ -1,6 +1,5 @@
using System;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Manager;
using Xunit;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;

View File

@ -3,116 +3,114 @@ using System.Collections.Generic;
using System.Linq;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Analyzer Helper.
/// </summary>
public class AnalyzerHelper
{
private readonly ILogger _logger;
private readonly double _silenceDetectionMinimumDuration;
/// <summary>
/// Analyzer Helper.
/// Initializes a new instance of the <see cref="AnalyzerHelper"/> class.
/// </summary>
public class AnalyzerHelper
/// <param name="logger">Logger.</param>
public AnalyzerHelper(ILogger logger)
{
private readonly ILogger _logger;
private readonly double _silenceDetectionMinimumDuration;
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
_silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration;
_logger = logger;
}
/// <summary>
/// Initializes a new instance of the <see cref="AnalyzerHelper"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public AnalyzerHelper(ILogger 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 Dictionary<Guid, Segment> AdjustIntroTimes(
IReadOnlyList<QueuedEpisode> episodes,
IReadOnlyDictionary<Guid, Segment> originalIntros,
AnalysisMode mode)
{
return episodes
.Where(episode => originalIntros.TryGetValue(episode.EpisodeId, out var _))
.ToDictionary(
episode => episode.EpisodeId,
episode => AdjustIntroForEpisode(episode, originalIntros[episode.EpisodeId], mode));
}
private Segment AdjustIntroForEpisode(QueuedEpisode episode, Segment originalIntro, AnalysisMode mode)
{
_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)
{
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
_silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration;
_logger = logger;
AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd);
}
/// <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 Dictionary<Guid, Segment> AdjustIntroTimes(
IReadOnlyList<QueuedEpisode> episodes,
IReadOnlyDictionary<Guid, Segment> originalIntros,
MediaSegmentType mode)
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++)
{
return episodes
.Where(episode => originalIntros.TryGetValue(episode.EpisodeId, out var _))
.ToDictionary(
episode => episode.EpisodeId,
episode => AdjustIntroForEpisode(episode, originalIntros[episode.EpisodeId], mode));
}
double currentTime = i < chapters.Count
? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds
: episode.Duration;
private Segment AdjustIntroForEpisode(QueuedEpisode episode, Segment originalIntro, MediaSegmentType mode)
{
_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 == MediaSegmentType.Intro)
if (originalIntroStart.Start < previousTime && previousTime < originalIntroStart.End)
{
AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd);
adjustedIntro.Start = previousTime;
_logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime);
}
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++)
if (originalIntroEnd.Start < currentTime && currentTime < originalIntroEnd.End)
{
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;
adjustedIntro.End = currentTime;
_logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime);
return true;
}
return false;
previousTime = currentTime;
}
private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment adjustedIntro, TimeRange originalIntroEnd)
{
var silence = FFmpegWrapper.DetectSilence(episode, originalIntroEnd);
return false;
}
foreach (var currentRange in silence)
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))
{
_logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, currentRange.Start, currentRange.End);
if (IsValidSilenceForIntroAdjustment(currentRange, originalIntroEnd, adjustedIntro))
{
adjustedIntro.End = currentRange.Start;
break;
}
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;
}
}
private bool IsValidSilenceForIntroAdjustment(TimeRange silenceRange, TimeRange originalIntroEnd, Segment adjustedIntro)
{
return originalIntroEnd.Intersects(silenceRange) &&
silenceRange.Duration >= _silenceDetectionMinimumDuration &&
silenceRange.Start >= adjustedIntro.Start;
}
}

View File

@ -4,7 +4,6 @@ using System.Linq;
using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
@ -42,10 +41,10 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
/// <inheritdoc />
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue,
MediaSegmentType mode,
AnalysisMode mode,
CancellationToken cancellationToken)
{
if (mode != MediaSegmentType.Outro)
if (mode != AnalysisMode.Credits)
{
throw new NotImplementedException("mode must equal Credits");
}

View File

@ -1,12 +1,12 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
@ -26,7 +26,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
/// <inheritdoc />
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue,
MediaSegmentType mode,
AnalysisMode mode,
CancellationToken cancellationToken)
{
var skippableRanges = new Dictionary<Guid, Segment>();
@ -34,7 +34,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
// Episode analysis queue.
var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue);
var expression = mode == MediaSegmentType.Intro ?
var expression = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern :
Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern;
@ -83,7 +83,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
QueuedEpisode episode,
IReadOnlyList<ChapterInfo> chapters,
string expression,
MediaSegmentType mode)
AnalysisMode mode)
{
var count = chapters.Count;
if (count == 0)
@ -92,7 +92,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
}
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var reversed = mode != MediaSegmentType.Intro;
var reversed = mode != AnalysisMode.Introduction;
var (minDuration, maxDuration) = reversed
? (config.MinimumCreditsDuration, config.MaximumCreditsDuration)
: (config.MinimumIntroDuration, config.MaximumIntroDuration);

View File

@ -6,7 +6,6 @@ using System.Numerics;
using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
@ -32,7 +31,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
private readonly ILogger<ChromaprintAnalyzer> _logger;
private MediaSegmentType _mediaSegmentType;
private AnalysisMode _analysisMode;
/// <summary>
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
@ -52,7 +51,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
/// <inheritdoc />
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue,
MediaSegmentType mode,
AnalysisMode mode,
CancellationToken cancellationToken)
{
// All intros for this season.
@ -67,7 +66,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
// Episodes that were analyzed and do not have an introduction.
var episodesWithoutIntros = episodeAnalysisQueue.Where(e => !e.State.IsAnalyzed(mode)).ToList();
_mediaSegmentType = mode;
_analysisMode = mode;
if (episodesWithoutIntros.Count == 0 || episodeAnalysisQueue.Count <= 1)
{
@ -97,7 +96,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode);
// Use reversed fingerprints for credits
if (_mediaSegmentType == MediaSegmentType.Outro)
if (_analysisMode == AnalysisMode.Credits)
{
Array.Reverse(fingerprintCache[episode.EpisodeId]);
}
@ -140,7 +139,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
// - the introduction exceeds the configured limit
if (
!remainingIntro.Valid ||
(_mediaSegmentType == MediaSegmentType.Intro && remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration))
(_analysisMode == AnalysisMode.Introduction && remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration))
{
continue;
}
@ -154,7 +153,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
* To fix this, the starting and ending times need to be switched, as they were previously reversed
* and subtracted from the episode duration to get the reported time range.
*/
if (_mediaSegmentType == MediaSegmentType.Outro)
if (_analysisMode == AnalysisMode.Credits)
{
// Calculate new values for the current intro
double currentOriginalIntroStart = currentIntro.Start;
@ -203,9 +202,9 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
// Adjust all introduction times.
var analyzerHelper = new AnalyzerHelper(_logger);
seasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, seasonIntros, _mediaSegmentType);
seasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, seasonIntros, _analysisMode);
Plugin.Instance!.UpdateTimestamps(seasonIntros, _mediaSegmentType);
Plugin.Instance!.UpdateTimestamps(seasonIntros, _analysisMode);
return episodeAnalysisQueue;
}
@ -297,8 +296,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, _mediaSegmentType);
var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, _mediaSegmentType);
var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, _analysisMode);
var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, _analysisMode);
var indexShifts = new HashSet<int>();
// For all audio points in the left episode, check if the right episode has a point which matches exactly.

View File

@ -1,7 +1,6 @@
using System.Collections.Generic;
using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Jellyfin.Data.Enums;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
@ -19,6 +18,6 @@ public interface IMediaFileAnalyzer
/// <returns>Collection of media files that were **unsuccessfully analyzed**.</returns>
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue,
MediaSegmentType mode,
AnalysisMode mode,
CancellationToken cancellationToken);
}

View File

@ -1,7 +1,7 @@
using System.Collections.Generic;
using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
@ -10,10 +10,21 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
/// </summary>
public class SegmentAnalyzer : IMediaFileAnalyzer
{
private readonly ILogger<SegmentAnalyzer> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SegmentAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public SegmentAnalyzer(ILogger<SegmentAnalyzer> logger)
{
_logger = logger;
}
/// <inheritdoc />
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue,
MediaSegmentType mode,
AnalysisMode mode,
CancellationToken cancellationToken)
{
return analysisQueue;

View File

@ -1035,16 +1035,16 @@
// Update the editor for the first and second episodes
timestampEditor.style.display = "unset";
document.querySelector("#editLeftEpisodeTitle").textContent = leftEpisode.text;
document.querySelector("#editLeftIntroEpisodeStartEdit").value = leftEpisodeJson.Intro.Start;
document.querySelector("#editLeftIntroEpisodeEndEdit").value = leftEpisodeJson.Intro.End;
document.querySelector("#editLeftCreditEpisodeStartEdit").value = leftEpisodeJson.Outro.Start;
document.querySelector("#editLeftCreditEpisodeEndEdit").value = leftEpisodeJson.Outro.End;
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("#editRightEpisodeTitle").textContent = rightEpisode.text;
document.querySelector("#editRightIntroEpisodeStartEdit").value = rightEpisodeJson.Intro.Start;
document.querySelector("#editRightIntroEpisodeEndEdit").value = rightEpisodeJson.Intro.End;
document.querySelector("#editRightCreditEpisodeStartEdit").value = rightEpisodeJson.Outro.Start;
document.querySelector("#editRightCreditEpisodeEndEdit").value = rightEpisodeJson.Outro.End;
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;
// Update display inputs
const inputs = document.querySelectorAll('#timestampEditor input[type="number"]');
@ -1259,11 +1259,11 @@
selectEpisode1.addEventListener("change", episodeChanged);
selectEpisode2.addEventListener("change", episodeChanged);
btnEraseIntroTimestamps.addEventListener("click", (e) => {
eraseTimestamps("Intro");
eraseTimestamps("Introduction");
e.preventDefault();
});
btnEraseCreditTimestamps.addEventListener("click", (e) => {
eraseTimestamps("Outro");
eraseTimestamps("Credits");
e.preventDefault();
});
btnSeasonEraseTimestamps.addEventListener("click", () => {
@ -1317,11 +1317,11 @@
const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value;
const newLhs = {
Intro: {
Introduction: {
Start: getEditValue("editLeftIntroEpisodeStartEdit"),
End: getEditValue("editLeftIntroEpisodeEndEdit"),
},
Outro: {
Credits: {
Start: getEditValue("editLeftCreditEpisodeStartEdit"),
End: getEditValue("editLeftCreditEpisodeEndEdit"),
},
@ -1329,11 +1329,11 @@
const rhsId = selectEpisode2.options[selectEpisode2.selectedIndex].value;
const newRhs = {
Intro: {
Introduction: {
Start: getEditValue("editRightIntroEpisodeStartEdit"),
End: getEditValue("editRightIntroEpisodeEndEdit"),
},
Outro: {
Credits: {
Start: getEditValue("editRightCreditEpisodeStartEdit"),
End: getEditValue("editRightCreditEpisodeEndEdit"),
},

View File

@ -186,8 +186,8 @@ const introSkipper = {
<span class="material-icons skip_next"></span>
</button>
`;
this.skipButton.dataset.Intro = config.SkipButtonIntroText;
this.skipButton.dataset.Outro = config.SkipButtonEndCreditsText;
this.skipButton.dataset.Introduction = config.SkipButtonIntroText;
this.skipButton.dataset.Credits = config.SkipButtonEndCreditsText;
const controls = document.querySelector("div#videoOsdPage");
controls.appendChild(this.skipButton);
},
@ -270,7 +270,7 @@ const introSkipper = {
}, 500);
};
this.videoPlayer.addEventListener('seeked', seekedHandler);
this.videoPlayer.currentTime = segment.SegmentType === "Outro" && this.videoPlayer.duration - segment.IntroEnd < 3
this.videoPlayer.currentTime = segment.SegmentType === "Credits" && this.videoPlayer.duration - segment.IntroEnd < 3
? this.videoPlayer.duration + 10
: segment.IntroEnd;
},
@ -391,11 +391,11 @@ const introSkipper = {
this.setTimeInputs(skipperFields);
},
updateSkipperFields(skipperFields) {
const { Intro = {}, Outro = {} } = this.skipperData;
skipperFields.querySelector('#introStartEdit').value = Intro.Start || 0;
skipperFields.querySelector('#introEndEdit').value = Intro.End || 0;
skipperFields.querySelector('#creditsStartEdit').value = Outro.Start || 0;
skipperFields.querySelector('#creditsEndEdit').value = Outro.End || 0;
const { Introduction = {}, Credits = {} } = this.skipperData;
skipperFields.querySelector('#introStartEdit').value = Introduction.Start || 0;
skipperFields.querySelector('#introEndEdit').value = Introduction.End || 0;
skipperFields.querySelector('#creditsStartEdit').value = Credits.Start || 0;
skipperFields.querySelector('#creditsEndEdit').value = Credits.End || 0;
},
attachSaveListener(metadataFormFields) {
const saveButton = metadataFormFields.querySelector('.formDialogFooter .btnSave');
@ -441,20 +441,20 @@ const introSkipper = {
},
async saveSkipperData() {
const newTimestamps = {
Intro: {
Introduction: {
Start: parseFloat(document.getElementById('introStartEdit').value || 0),
End: parseFloat(document.getElementById('introEndEdit').value || 0)
},
Outro: {
Credits: {
Start: parseFloat(document.getElementById('creditsStartEdit').value || 0),
End: parseFloat(document.getElementById('creditsEndEdit').value || 0)
}
};
const { Intro = {}, Outro = {} } = this.skipperData;
if (newTimestamps.Intro.Start !== (Intro.Start || 0) ||
newTimestamps.Intro.End !== (Intro.End || 0) ||
newTimestamps.Outro.Start !== (Outro.Start || 0) ||
newTimestamps.Outro.End !== (Outro.End || 0)) {
const { Introduction = {}, Credits = {} } = this.skipperData;
if (newTimestamps.Introduction.Start !== (Introduction.Start || 0) ||
newTimestamps.Introduction.End !== (Introduction.End || 0) ||
newTimestamps.Credits.Start !== (Credits.Start || 0) ||
newTimestamps.Credits.End !== (Credits.End || 0)) {
const response = await this.secureFetch(`Episode/${this.currentEpisodeId}/Timestamps`, "POST", JSON.stringify(newTimestamps));
this.d(response.ok ? 'Timestamps updated successfully' : 'Failed to update timestamps:', response.status);
} else {

View File

@ -23,4 +23,8 @@
<EmbeddedResource Include="Configuration\visualizer.js" />
<EmbeddedResource Include="Configuration\inject.js" />
</ItemGroup>
<ItemGroup>
<Folder Include="Manager\" />
<Folder Include="Services\" />
</ItemGroup>
</Project>

View File

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Net.Mime;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Entities.TV;
using Microsoft.AspNetCore.Authorization;
@ -38,7 +37,7 @@ public class SkipIntroController : ControllerBase
[HttpGet("Episode/{id}/IntroTimestamps/v1")]
public ActionResult<Intro> GetIntroTimestamps(
[FromRoute] Guid id,
[FromQuery] MediaSegmentType mode = MediaSegmentType.Intro)
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
{
var intro = GetIntro(id, mode);
@ -81,8 +80,8 @@ public class SkipIntroController : ControllerBase
Plugin.Instance!.Credits[id] = new Segment(id, cr);
}
Plugin.Instance!.SaveTimestamps(MediaSegmentType.Intro);
Plugin.Instance!.SaveTimestamps(MediaSegmentType.Outro);
Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction);
Plugin.Instance!.SaveTimestamps(AnalysisMode.Credits);
return NoContent();
}
@ -126,18 +125,18 @@ public class SkipIntroController : ControllerBase
/// <response code="200">Skippable segments dictionary.</response>
/// <returns>Dictionary of skippable segments.</returns>
[HttpGet("Episode/{id}/IntroSkipperSegments")]
public ActionResult<Dictionary<MediaSegmentType, Intro>> GetSkippableSegments([FromRoute] Guid id)
public ActionResult<Dictionary<AnalysisMode, Intro>> GetSkippableSegments([FromRoute] Guid id)
{
var segments = new Dictionary<MediaSegmentType, Intro>();
var segments = new Dictionary<AnalysisMode, Intro>();
if (GetIntro(id, MediaSegmentType.Intro) is Intro intro)
if (GetIntro(id, AnalysisMode.Introduction) is Intro intro)
{
segments[MediaSegmentType.Intro] = intro;
segments[AnalysisMode.Introduction] = intro;
}
if (GetIntro(id, MediaSegmentType.Outro) is Intro credits)
if (GetIntro(id, AnalysisMode.Credits) is Intro credits)
{
segments[MediaSegmentType.Outro] = credits;
segments[AnalysisMode.Credits] = credits;
}
return segments;
@ -147,7 +146,7 @@ public class SkipIntroController : ControllerBase
/// <param name="id">Unique identifier of this episode.</param>
/// <param name="mode">Mode.</param>
/// <returns>Intro object if the provided item has an intro, null otherwise.</returns>
private static Intro? GetIntro(Guid id, MediaSegmentType mode)
private static Intro? GetIntro(Guid id, AnalysisMode mode)
{
try
{
@ -188,13 +187,13 @@ public class SkipIntroController : ControllerBase
/// <returns>No content.</returns>
[Authorize(Policy = Policies.RequiresElevation)]
[HttpPost("Intros/EraseTimestamps")]
public ActionResult ResetIntroTimestamps([FromQuery] MediaSegmentType mode, [FromQuery] bool eraseCache = false)
public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false)
{
if (mode == MediaSegmentType.Intro)
if (mode == AnalysisMode.Introduction)
{
Plugin.Instance!.Intros.Clear();
}
else if (mode == MediaSegmentType.Outro)
else if (mode == AnalysisMode.Credits)
{
Plugin.Instance!.Credits.Clear();
}

View File

@ -4,7 +4,6 @@ using System.Globalization;
using System.Linq;
using System.Net.Mime;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Api;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -115,8 +114,8 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
return new IgnoreListItem(Guid.Empty)
{
IgnoreIntro = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, MediaSegmentType.Intro)),
IgnoreCredits = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, MediaSegmentType.Outro))
IgnoreIntro = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, AnalysisMode.Introduction)),
IgnoreCredits = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, AnalysisMode.Credits))
};
}
@ -159,7 +158,7 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
{
if (needle.EpisodeId == id)
{
return FFmpegWrapper.Fingerprint(needle, MediaSegmentType.Intro);
return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction);
}
}
}
@ -202,7 +201,7 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
}
}
Plugin.Instance!.SaveTimestamps(MediaSegmentType.Intro | MediaSegmentType.Outro);
Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction | AnalysisMode.Credits);
return NoContent();
}
@ -282,7 +281,7 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
{
var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd);
Plugin.Instance!.Intros[id] = new Segment(id, tr);
Plugin.Instance.SaveTimestamps(MediaSegmentType.Intro);
Plugin.Instance.SaveTimestamps(AnalysisMode.Introduction);
}
return NoContent();

View File

@ -0,0 +1,17 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
/// <summary>
/// Type of media file analysis to perform.
/// </summary>
public enum AnalysisMode
{
/// <summary>
/// Detect introduction sequences.
/// </summary>
Introduction,
/// <summary>
/// Detect credits.
/// </summary>
Credits,
}

View File

@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
@ -10,61 +7,44 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
/// </summary>
public class EpisodeState
{
private readonly Dictionary<MediaSegmentType, (bool Analyzed, bool Blacklisted)> _states = [];
private readonly bool[] _analyzedStates = new bool[2];
/// <summary>
/// Initializes a new instance of the <see cref="EpisodeState"/> class.
/// </summary>
public EpisodeState() =>
Array.ForEach(Enum.GetValues<MediaSegmentType>(), mode => _states[mode] = default);
private readonly bool[] _blacklistedStates = new bool[2];
/// <summary>
/// Checks if the specified analysis mode has been analyzed.
/// </summary>
/// <param name="mode">The analysis mode to check.</param>
/// <returns>True if the mode has been analyzed, false otherwise.</returns>
public bool IsAnalyzed(MediaSegmentType mode) => _states[mode].Analyzed;
/// <summary>
/// Checks if the specified analysis mode has been blacklisted.
/// </summary>
/// <param name="mode">The analysis mode to check.</param>
/// <returns>True if the mode has been blacklisted, false otherwise.</returns>
public bool IsBlacklisted(MediaSegmentType mode) => _states[mode].Blacklisted;
public bool IsAnalyzed(AnalysisMode mode) => _analyzedStates[(int)mode];
/// <summary>
/// Sets the analyzed state for the specified analysis mode.
/// </summary>
/// <param name="mode">The analysis mode to set.</param>
/// <param name="value">The analyzed state to set.</param>
public void SetAnalyzed(MediaSegmentType mode, bool value) =>
_states[mode] = (value, _states[mode].Blacklisted);
public void SetAnalyzed(AnalysisMode mode, bool value) => _analyzedStates[(int)mode] = value;
/// <summary>
/// Checks if the specified analysis mode has been blacklisted.
/// </summary>
/// <param name="mode">The analysis mode to check.</param>
/// <returns>True if the mode has been blacklisted, false otherwise.</returns>
public bool IsBlacklisted(AnalysisMode mode) => _blacklistedStates[(int)mode];
/// <summary>
/// Sets the blacklisted state for the specified analysis mode.
/// </summary>
/// <param name="mode">The analysis mode to set.</param>
/// <param name="value">The blacklisted state to set.</param>
public void SetBlacklisted(MediaSegmentType mode, bool value) =>
_states[mode] = (_states[mode].Analyzed, value);
public void SetBlacklisted(AnalysisMode mode, bool value) => _blacklistedStates[(int)mode] = value;
/// <summary>
/// Resets all states to their default values.
/// Resets the analyzed states.
/// </summary>
public void ResetStates() =>
Array.ForEach(Enum.GetValues<MediaSegmentType>(), mode => _states[mode] = default);
/// <summary>
/// Gets all modes that have been analyzed.
/// </summary>
/// <returns>An IEnumerable of analyzed MediaSegmentTypes.</returns>
public IEnumerable<MediaSegmentType> GetAnalyzedModes() =>
_states.Where(kvp => kvp.Value.Analyzed).Select(kvp => kvp.Key);
/// <summary>
/// Gets all modes that have been blacklisted.
/// </summary>
/// <returns>An IEnumerable of blacklisted MediaSegmentTypes.</returns>
public IEnumerable<MediaSegmentType> GetBlacklistedModes() =>
_states.Where(kvp => kvp.Value.Blacklisted).Select(kvp => kvp.Key);
public void ResetStates()
{
Array.Clear(_analyzedStates);
Array.Clear(_blacklistedStates);
}
}

View File

@ -1,6 +1,5 @@
using System;
using System.Runtime.Serialization;
using Jellyfin.Data.Enums;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
@ -60,14 +59,14 @@ public class IgnoreListItem
/// </summary>
/// <param name="mode">Analysis mode.</param>
/// <param name="value">Value to set.</param>
public void Toggle(MediaSegmentType mode, bool value)
public void Toggle(AnalysisMode mode, bool value)
{
switch (mode)
{
case MediaSegmentType.Intro:
case AnalysisMode.Introduction:
IgnoreIntro = value;
break;
case MediaSegmentType.Outro:
case AnalysisMode.Credits:
IgnoreCredits = value;
break;
}
@ -78,12 +77,12 @@ public class IgnoreListItem
/// </summary>
/// <param name="mode">Analysis mode.</param>
/// <returns>True if ignored, false otherwise.</returns>
public bool IsIgnored(MediaSegmentType mode)
public bool IsIgnored(AnalysisMode mode)
{
return mode switch
{
MediaSegmentType.Intro => IgnoreIntro,
MediaSegmentType.Outro => IgnoreCredits,
AnalysisMode.Introduction => IgnoreIntro,
AnalysisMode.Credits => IgnoreCredits,
_ => false,
};
}

View File

@ -7,7 +7,6 @@ using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
@ -34,7 +33,7 @@ public static partial class FFmpegWrapper
private static Dictionary<string, string> ChromaprintLogs { get; set; } = [];
private static ConcurrentDictionary<(Guid Id, MediaSegmentType Mode), Dictionary<uint, int>> InvertedIndexCache { get; set; } = new();
private static ConcurrentDictionary<(Guid Id, AnalysisMode Mode), Dictionary<uint, int>> InvertedIndexCache { get; set; } = new();
/// <summary>
/// Check that the installed version of ffmpeg supports chromaprint.
@ -110,16 +109,16 @@ public static partial class FFmpegWrapper
/// <param name="episode">Queued episode to fingerprint.</param>
/// <param name="mode">Portion of media file to fingerprint. Introduction = first 25% / 10 minutes and Credits = last 4 minutes.</param>
/// <returns>Numerical fingerprint points.</returns>
public static uint[] Fingerprint(QueuedEpisode episode, MediaSegmentType mode)
public static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode)
{
int start, end;
if (mode == MediaSegmentType.Intro)
if (mode == AnalysisMode.Introduction)
{
start = 0;
end = episode.IntroFingerprintEnd;
}
else if (mode == MediaSegmentType.Outro)
else if (mode == AnalysisMode.Credits)
{
start = episode.CreditsFingerprintStart;
end = episode.Duration;
@ -139,7 +138,7 @@ public static partial class FFmpegWrapper
/// <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, MediaSegmentType mode)
public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint, AnalysisMode mode)
{
if (InvertedIndexCache.TryGetValue((id, mode), out var cached))
{
@ -469,7 +468,7 @@ public static partial class FFmpegWrapper
/// <param name="start">Time (in seconds) relative to the start of the file to start fingerprinting from.</param>
/// <param name="end">Time (in seconds) relative to the start of the file to stop fingerprinting at.</param>
/// <returns>Numerical fingerprint points.</returns>
private static uint[] Fingerprint(QueuedEpisode episode, MediaSegmentType mode, int start, int end)
private static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode, int start, int end)
{
// Try to load this episode from cache before running ffmpeg.
if (LoadCachedFingerprint(episode, mode, out uint[] cachedFingerprint))
@ -523,7 +522,7 @@ public static partial class FFmpegWrapper
/// <returns>true if the episode was successfully loaded from cache, false on any other error.</returns>
private static bool LoadCachedFingerprint(
QueuedEpisode episode,
MediaSegmentType mode,
AnalysisMode mode,
out uint[] fingerprint)
{
fingerprint = Array.Empty<uint>();
@ -579,7 +578,7 @@ public static partial class FFmpegWrapper
/// <param name="fingerprint">Fingerprint of the episode to store.</param>
private static void CacheFingerprint(
QueuedEpisode episode,
MediaSegmentType mode,
AnalysisMode mode,
List<uint> fingerprint)
{
// Bail out if caching isn't enabled.
@ -628,11 +627,11 @@ public static partial class FFmpegWrapper
/// Remove cached fingerprints from disk by mode.
/// </summary>
/// <param name="mode">Analysis mode.</param>
public static void DeleteCacheFiles(MediaSegmentType mode)
public static void DeleteCacheFiles(AnalysisMode mode)
{
foreach (var filePath in Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath))
{
var shouldDelete = (mode == MediaSegmentType.Intro)
var shouldDelete = (mode == AnalysisMode.Introduction)
? !filePath.Contains("credit", StringComparison.OrdinalIgnoreCase)
&& !filePath.Contains("blackframes", StringComparison.OrdinalIgnoreCase)
: filePath.Contains("credit", StringComparison.OrdinalIgnoreCase)
@ -652,18 +651,18 @@ public static partial class FFmpegWrapper
/// <param name="episode">Episode.</param>
/// <param name="mode">Analysis mode.</param>
/// <returns>Path.</returns>
public static string GetFingerprintCachePath(QueuedEpisode episode, MediaSegmentType mode)
public static string GetFingerprintCachePath(QueuedEpisode episode, AnalysisMode mode)
{
var basePath = Path.Join(
Plugin.Instance!.FingerprintCachePath,
episode.EpisodeId.ToString("N"));
if (mode == MediaSegmentType.Intro)
if (mode == AnalysisMode.Introduction)
{
return basePath;
}
if (mode == MediaSegmentType.Outro)
if (mode == AnalysisMode.Credits)
{
return basePath + "-credits";
}

View File

@ -6,7 +6,7 @@ using System.Runtime.Serialization;
using System.Xml;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Helper
namespace ConfusedPolarBear.Plugin.IntroSkipper
{
internal sealed class XmlSerializationHelper
{

View File

@ -4,114 +4,113 @@ using System.IO;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Update EDL files associated with a list of episodes.
/// </summary>
public static class EdlManager
{
private static ILogger? _logger;
/// <summary>
/// Update EDL files associated with a list of episodes.
/// Initialize EDLManager with a logger.
/// </summary>
public static class EdlManager
/// <param name="logger">ILogger.</param>
public static void Initialize(ILogger logger)
{
private static ILogger? _logger;
_logger = logger;
}
/// <summary>
/// Initialize EDLManager with a logger.
/// </summary>
/// <param name="logger">ILogger.</param>
public static void Initialize(ILogger logger)
/// <summary>
/// Logs the configuration that will be used during EDL file creation.
/// </summary>
public static void LogConfiguration()
{
if (_logger is null)
{
_logger = logger;
throw new InvalidOperationException("Logger must not be null");
}
/// <summary>
/// Logs the configuration that will be used during EDL file creation.
/// </summary>
public static void LogConfiguration()
var config = Plugin.Instance!.Configuration;
if (config.EdlAction == EdlAction.None)
{
if (_logger is null)
{
throw new InvalidOperationException("Logger must not be null");
}
var config = Plugin.Instance!.Configuration;
if (config.EdlAction == EdlAction.None)
{
_logger.LogDebug("EDL action: None - taking no further action");
return;
}
_logger.LogDebug("EDL action: {Action}", config.EdlAction);
_logger.LogDebug("Regenerate EDL files: {Regenerate}", config.RegenerateEdlFiles);
_logger.LogDebug("EDL action: None - taking no further action");
return;
}
/// <summary>
/// If the EDL action is set to a value other than None, update EDL files for the provided episodes.
/// </summary>
/// <param name="episodes">Episodes to update EDL files for.</param>
public static void UpdateEDLFiles(IReadOnlyList<QueuedEpisode> episodes)
_logger.LogDebug("EDL action: {Action}", config.EdlAction);
_logger.LogDebug("Regenerate EDL files: {Regenerate}", config.RegenerateEdlFiles);
}
/// <summary>
/// If the EDL action is set to a value other than None, update EDL files for the provided episodes.
/// </summary>
/// <param name="episodes">Episodes to update EDL files for.</param>
public static void UpdateEDLFiles(IReadOnlyList<QueuedEpisode> episodes)
{
var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles;
var action = Plugin.Instance.Configuration.EdlAction;
if (action == EdlAction.None)
{
var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles;
var action = Plugin.Instance.Configuration.EdlAction;
if (action == EdlAction.None)
{
_logger?.LogDebug("EDL action is set to none, not updating EDL files");
return;
}
_logger?.LogDebug("Updating EDL files with action {Action}", action);
foreach (var episode in episodes)
{
var id = episode.EpisodeId;
bool hasIntro = Plugin.Instance!.Intros.TryGetValue(id, out var intro) && intro.Valid;
bool hasCredit = Plugin.Instance!.Credits.TryGetValue(id, out var credit) && credit.Valid;
if (!hasIntro && !hasCredit)
{
_logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id);
continue;
}
var edlPath = GetEdlPath(Plugin.Instance.GetItemPath(id));
_logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath);
if (!regenerate && File.Exists(edlPath))
{
_logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath);
continue;
}
var edlContent = string.Empty;
if (hasIntro)
{
edlContent += intro?.ToEdl(action);
}
if (hasCredit)
{
if (edlContent.Length > 0)
{
edlContent += Environment.NewLine;
}
edlContent += credit?.ToEdl(action);
}
File.WriteAllText(edlPath, edlContent);
}
_logger?.LogDebug("EDL action is set to none, not updating EDL files");
return;
}
/// <summary>
/// Given the path to an episode, return the path to the associated EDL file.
/// </summary>
/// <param name="mediaPath">Full path to episode.</param>
/// <returns>Full path to EDL file.</returns>
public static string GetEdlPath(string mediaPath)
_logger?.LogDebug("Updating EDL files with action {Action}", action);
foreach (var episode in episodes)
{
return Path.ChangeExtension(mediaPath, "edl");
var id = episode.EpisodeId;
bool hasIntro = Plugin.Instance!.Intros.TryGetValue(id, out var intro) && intro.Valid;
bool hasCredit = Plugin.Instance!.Credits.TryGetValue(id, out var credit) && credit.Valid;
if (!hasIntro && !hasCredit)
{
_logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id);
continue;
}
var edlPath = GetEdlPath(Plugin.Instance.GetItemPath(id));
_logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath);
if (!regenerate && File.Exists(edlPath))
{
_logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath);
continue;
}
var edlContent = string.Empty;
if (hasIntro)
{
edlContent += intro?.ToEdl(action);
}
if (hasCredit)
{
if (edlContent.Length > 0)
{
edlContent += Environment.NewLine;
}
edlContent += credit?.ToEdl(action);
}
File.WriteAllText(edlPath, edlContent);
}
}
/// <summary>
/// Given the path to an episode, return the path to the associated EDL file.
/// </summary>
/// <param name="mediaPath">Full path to episode.</param>
/// <returns>Full path to EDL file.</returns>
public static string GetEdlPath(string mediaPath)
{
return Path.ChangeExtension(mediaPath, "edl");
}
}

View File

@ -10,281 +10,287 @@ using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Manages enqueuing library items for analysis.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="QueueManager"/> class.
/// </remarks>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryManager)
{
private readonly ILibraryManager _libraryManager = libraryManager;
private readonly ILogger<QueueManager> _logger = logger;
private readonly Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes = [];
private double _analysisPercent;
private List<string> _selectedLibraries = [];
private bool _selectAllLibraries;
/// <summary>
/// Manages enqueuing library items for analysis.
/// Gets all media items on the server.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="QueueManager"/> class.
/// </remarks>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryManager)
/// <returns>Queued media items.</returns>
public IReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems()
{
private readonly ILibraryManager _libraryManager = libraryManager;
private readonly ILogger<QueueManager> _logger = logger;
private readonly Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes = [];
private double _analysisPercent;
private List<string> _selectedLibraries = [];
private bool _selectAllLibraries;
Plugin.Instance!.TotalQueued = 0;
/// <summary>
/// Gets all media items on the server.
/// </summary>
/// <returns>Queued media items.</returns>
public IReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems()
LoadAnalysisSettings();
// For all selected libraries, enqueue all contained episodes.
foreach (var folder in _libraryManager.GetVirtualFolders())
{
Plugin.Instance!.TotalQueued = 0;
LoadAnalysisSettings();
// For all selected libraries, enqueue all contained episodes.
foreach (var folder in _libraryManager.GetVirtualFolders())
// If libraries have been selected for analysis, ensure this library was selected.
if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name))
{
// If libraries have been selected for analysis, ensure this library was selected.
if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name))
{
_logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name);
continue;
}
_logger.LogInformation("Running enqueue of items in library {Name}", folder.Name);
// Some virtual folders don't have a proper item id.
if (!Guid.TryParse(folder.ItemId, out var folderId))
{
continue;
}
try
{
QueueLibraryContents(folderId);
}
catch (Exception ex)
{
_logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex);
}
_logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name);
continue;
}
Plugin.Instance.TotalSeasons = _queuedEpisodes.Count;
Plugin.Instance.QueuedMediaItems.Clear();
_logger.LogInformation("Running enqueue of items in library {Name}", folder.Name);
// Some virtual folders don't have a proper item id.
if (!Guid.TryParse(folder.ItemId, out var folderId))
{
continue;
}
try
{
QueueLibraryContents(folderId);
}
catch (Exception ex)
{
_logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex);
}
}
Plugin.Instance.TotalSeasons = _queuedEpisodes.Count;
Plugin.Instance.QueuedMediaItems.Clear();
foreach (var kvp in _queuedEpisodes)
{
Plugin.Instance.QueuedMediaItems.TryAdd(kvp.Key, kvp.Value);
}
return _queuedEpisodes;
}
/// <summary>
/// Loads the list of libraries which have been selected for analysis and the minimum intro duration.
/// Settings which have been modified from the defaults are logged.
/// </summary>
private void LoadAnalysisSettings()
{
var config = Plugin.Instance!.Configuration;
// Store the analysis percent
_analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100;
_selectAllLibraries = config.SelectAllLibraries;
if (!_selectAllLibraries)
{
// Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries.
_selectedLibraries = [.. config.SelectedLibraries.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
// If any libraries have been selected for analysis, log their names.
_logger.LogInformation("Limiting analysis to the following libraries: {Selected}", _selectedLibraries);
}
else
{
_logger.LogDebug("Not limiting analysis by library name");
}
// If analysis settings have been changed from the default, log the modified settings.
if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15)
{
_logger.LogInformation(
"Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s",
config.AnalysisPercent,
config.AnalysisLengthLimit,
config.MinimumIntroDuration);
}
}
private void QueueLibraryContents(Guid id)
{
_logger.LogDebug("Constructing anonymous internal query");
var query = new InternalItemsQuery
{
// Order by series name, season, and then episode number so that status updates are logged in order
ParentId = id,
OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),],
IncludeItemTypes = [BaseItemKind.Episode],
Recursive = true,
IsVirtualItem = false
};
var items = _libraryManager.GetItemList(query, false);
if (items is null)
{
_logger.LogError("Library query result is null");
return;
}
// Queue all episodes on the server for fingerprinting.
_logger.LogDebug("Iterating through library items");
foreach (var item in items)
{
if (item is not Episode episode)
{
_logger.LogDebug("Item {Name} is not an episode", item.Name);
continue;
}
QueueEpisode(episode);
}
_logger.LogDebug("Queued {Count} episodes", items.Count);
}
private void QueueEpisode(Episode episode)
{
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");
if (string.IsNullOrEmpty(episode.Path))
{
_logger.LogWarning(
"Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin",
episode.Name,
episode.SeriesName,
episode.Id);
return;
}
// Allocate a new list for each new season
var seasonId = GetSeasonId(episode);
if (!_queuedEpisodes.TryGetValue(seasonId, out var seasonEpisodes))
{
seasonEpisodes = [];
_queuedEpisodes[seasonId] = seasonEpisodes;
}
if (seasonEpisodes.Any(e => e.EpisodeId == episode.Id))
{
_logger.LogDebug(
"\"{Name}\" from series \"{Series}\" ({Id}) is already queued",
episode.Name,
episode.SeriesName,
episode.Id);
return;
}
var isAnime = seasonEpisodes.FirstOrDefault()?.IsAnime ??
(pluginInstance.GetItem(episode.SeriesId) is Series series &&
(series.Tags.Contains("anime", StringComparison.OrdinalIgnoreCase) ||
series.Genres.Contains("anime", StringComparison.OrdinalIgnoreCase)));
// Limit analysis to the first X% of the episode and at most Y minutes.
// X and Y default to 25% and 10 minutes.
var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
var fingerprintDuration = Math.Min(
duration >= 5 * 60 ? duration * _analysisPercent : duration,
60 * pluginInstance.Configuration.AnalysisLengthLimit);
// Queue the episode for analysis
var maxCreditsDuration = pluginInstance.Configuration.MaximumCreditsDuration;
seasonEpisodes.Add(new QueuedEpisode
{
SeriesName = episode.SeriesName,
SeasonNumber = episode.AiredSeasonNumber ?? 0,
SeriesId = episode.SeriesId,
EpisodeId = episode.Id,
Name = episode.Name,
IsAnime = isAnime,
Path = episode.Path,
Duration = Convert.ToInt32(duration),
IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration),
CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration),
});
pluginInstance.TotalQueued++;
}
private Guid GetSeasonId(Episode episode)
{
if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special
{
foreach (var kvp in _queuedEpisodes)
{
Plugin.Instance.QueuedMediaItems.TryAdd(kvp.Key, kvp.Value);
}
return _queuedEpisodes;
}
/// <summary>
/// Loads the list of libraries which have been selected for analysis and the minimum intro duration.
/// Settings which have been modified from the defaults are logged.
/// </summary>
private void LoadAnalysisSettings()
{
var config = Plugin.Instance!.Configuration;
// Store the analysis percent
_analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100;
_selectAllLibraries = config.SelectAllLibraries;
if (!_selectAllLibraries)
{
// Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries.
_selectedLibraries = [.. config.SelectedLibraries.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
// If any libraries have been selected for analysis, log their names.
_logger.LogInformation("Limiting analysis to the following libraries: {Selected}", _selectedLibraries);
}
else
{
_logger.LogDebug("Not limiting analysis by library name");
}
// If analysis settings have been changed from the default, log the modified settings.
if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15)
{
_logger.LogInformation(
"Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s",
config.AnalysisPercent,
config.AnalysisLengthLimit,
config.MinimumIntroDuration);
}
}
private void QueueLibraryContents(Guid id)
{
_logger.LogDebug("Constructing anonymous internal query");
var query = new InternalItemsQuery
{
// Order by series name, season, and then episode number so that status updates are logged in order
ParentId = id,
OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),],
IncludeItemTypes = [BaseItemKind.Episode],
Recursive = true,
IsVirtualItem = false
};
var items = _libraryManager.GetItemList(query, false);
if (items is null)
{
_logger.LogError("Library query result is null");
return;
}
// Queue all episodes on the server for fingerprinting.
_logger.LogDebug("Iterating through library items");
foreach (var item in items)
{
if (item is not Episode episode)
var first = kvp.Value.FirstOrDefault();
if (first?.SeriesId == episode.SeriesId &&
first.SeasonNumber == episode.AiredSeasonNumber)
{
return kvp.Key;
}
}
}
return episode.SeasonId;
}
/// <summary>
/// Verify that a collection of queued media items still exist in Jellyfin and in storage.
/// This is done to ensure that we don't analyze items that were deleted between the call to GetMediaItems() and popping them from the queue.
/// </summary>
/// <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)
VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<AnalysisMode> modes)
{
var verified = new List<QueuedEpisode>();
var reqModes = new HashSet<AnalysisMode>();
foreach (var candidate in candidates)
{
try
{
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
if (!File.Exists(path))
{
_logger.LogDebug("Item {Name} is not an episode", item.Name);
continue;
}
QueueEpisode(episode);
}
verified.Add(candidate);
_logger.LogDebug("Queued {Count} episodes", items.Count);
}
private void QueueEpisode(Episode episode)
{
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");
if (string.IsNullOrEmpty(episode.Path))
{
_logger.LogWarning(
"Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin",
episode.Name,
episode.SeriesName,
episode.Id);
return;
}
// Allocate a new list for each new season
var seasonId = GetSeasonId(episode);
if (!_queuedEpisodes.TryGetValue(seasonId, out var seasonEpisodes))
{
seasonEpisodes = [];
_queuedEpisodes[seasonId] = seasonEpisodes;
}
if (seasonEpisodes.Any(e => e.EpisodeId == episode.Id))
{
_logger.LogDebug(
"\"{Name}\" from series \"{Series}\" ({Id}) is already queued",
episode.Name,
episode.SeriesName,
episode.Id);
return;
}
var isAnime = seasonEpisodes.FirstOrDefault()?.IsAnime ??
(pluginInstance.GetItem(episode.SeriesId) is Series series &&
(series.Tags.Contains("anime", StringComparison.OrdinalIgnoreCase) ||
series.Genres.Contains("anime", StringComparison.OrdinalIgnoreCase)));
// Limit analysis to the first X% of the episode and at most Y minutes.
// X and Y default to 25% and 10 minutes.
var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
var fingerprintDuration = Math.Min(
duration >= 5 * 60 ? duration * _analysisPercent : duration,
60 * pluginInstance.Configuration.AnalysisLengthLimit);
// Queue the episode for analysis
var maxCreditsDuration = pluginInstance.Configuration.MaximumCreditsDuration;
seasonEpisodes.Add(new QueuedEpisode
{
SeriesName = episode.SeriesName,
SeasonNumber = episode.AiredSeasonNumber ?? 0,
SeriesId = episode.SeriesId,
EpisodeId = episode.Id,
Name = episode.Name,
IsAnime = isAnime,
Path = episode.Path,
Duration = Convert.ToInt32(duration),
IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration),
CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration),
});
pluginInstance.TotalQueued++;
}
private Guid GetSeasonId(Episode episode)
{
if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special
{
foreach (var kvp in _queuedEpisodes)
foreach (var mode in modes)
{
var first = kvp.Value.FirstOrDefault();
if (first?.SeriesId == episode.SeriesId &&
first.SeasonNumber == episode.AiredSeasonNumber)
{
return kvp.Key;
}
}
}
return episode.SeasonId;
}
/// <summary>
/// Verify that a collection of queued media items still exist in Jellyfin and in storage.
/// This is done to ensure that we don't analyze items that were deleted between the call to GetMediaItems() and popping them from the queue.
/// </summary>
/// <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<MediaSegmentType> RequiredModes)
VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<MediaSegmentType> modes)
{
var verified = new List<QueuedEpisode>();
var reqModes = new HashSet<MediaSegmentType>(modes);
foreach (var candidate in candidates)
{
try
{
if (!File.Exists(Plugin.Instance!.GetItemPath(candidate.EpisodeId)))
if (candidate.State.IsAnalyzed(mode) || candidate.State.IsBlacklisted(mode))
{
continue;
}
verified.Add(candidate);
reqModes.ExceptWith(candidate.State.GetAnalyzedModes());
reqModes.ExceptWith(candidate.State.GetBlacklistedModes());
bool isAnalyzed = mode == AnalysisMode.Introduction
? Plugin.Instance!.Intros.ContainsKey(candidate.EpisodeId)
: Plugin.Instance!.Credits.ContainsKey(candidate.EpisodeId);
if (reqModes.Remove(MediaSegmentType.Intro) && Plugin.Instance.Intros.ContainsKey(candidate.EpisodeId))
if (isAnalyzed)
{
candidate.State.SetAnalyzed(MediaSegmentType.Intro, true);
candidate.State.SetAnalyzed(mode, true);
}
else
{
reqModes.Add(MediaSegmentType.Intro);
reqModes.Add(mode);
}
if (reqModes.Remove(MediaSegmentType.Outro) && Plugin.Instance.Credits.ContainsKey(candidate.EpisodeId))
{
candidate.State.SetAnalyzed(MediaSegmentType.Outro, true);
}
else
{
reqModes.Add(MediaSegmentType.Outro);
}
}
catch (Exception ex)
{
_logger.LogDebug("Skipping analysis of {Name} ({Id}): {Exception}", candidate.Name, candidate.EpisodeId, ex);
}
}
return (verified, reqModes);
catch (Exception ex)
{
_logger.LogDebug(
"Skipping analysis of {Name} ({Id}): {Exception}",
candidate.Name,
candidate.EpisodeId,
ex);
}
}
return (verified, reqModes);
}
}

View File

@ -6,8 +6,6 @@ using System.Linq;
using System.Text.RegularExpressions;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Helper;
using Jellyfin.Data.Enums;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
@ -184,16 +182,16 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// Save timestamps to disk.
/// </summary>
/// <param name="mode">Mode.</param>
public void SaveTimestamps(MediaSegmentType mode)
public void SaveTimestamps(AnalysisMode mode)
{
List<Segment> introList = [];
var filePath = mode == MediaSegmentType.Intro
var filePath = mode == AnalysisMode.Introduction
? _introPath
: _creditsPath;
lock (_introsLock)
{
introList.AddRange(mode == MediaSegmentType.Intro
introList.AddRange(mode == AnalysisMode.Introduction
? Instance!.Intros.Values
: Instance!.Credits.Values);
}
@ -237,7 +235,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <param name="id">Item id.</param>
/// <param name="mode">Mode.</param>
/// <returns>True if ignored, false otherwise.</returns>
public bool IsIgnored(Guid id, MediaSegmentType mode)
public bool IsIgnored(Guid id, AnalysisMode mode)
{
return Instance!.IgnoreList.TryGetValue(id, out var item) && item.IsIgnored(mode);
}
@ -314,9 +312,9 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <param name="id">Item id.</param>
/// <param name="mode">Mode.</param>
/// <returns>Intro.</returns>
internal static Segment GetIntroByMode(Guid id, MediaSegmentType mode)
internal static Segment GetIntroByMode(Guid id, AnalysisMode mode)
{
return mode == MediaSegmentType.Intro
return mode == AnalysisMode.Introduction
? Instance!.Intros[id]
: Instance!.Credits[id];
}
@ -375,15 +373,15 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <returns>State of this item.</returns>
internal EpisodeState GetState(Guid id) => EpisodeStates.GetOrAdd(id, _ => new EpisodeState());
internal void UpdateTimestamps(IReadOnlyDictionary<Guid, Segment> newTimestamps, MediaSegmentType mode)
internal void UpdateTimestamps(IReadOnlyDictionary<Guid, Segment> newTimestamps, AnalysisMode mode)
{
foreach (var intro in newTimestamps)
{
if (mode == MediaSegmentType.Intro)
if (mode == AnalysisMode.Introduction)
{
Instance!.Intros.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value);
}
else if (mode == MediaSegmentType.Outro)
else if (mode == AnalysisMode.Credits)
{
Instance!.Credits.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value);
}
@ -406,8 +404,8 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
}
}
SaveTimestamps(MediaSegmentType.Intro);
SaveTimestamps(MediaSegmentType.Outro);
SaveTimestamps(AnalysisMode.Introduction);
SaveTimestamps(AnalysisMode.Credits);
}
private void MigrateRepoUrl(IServerConfigurationManager serverConfiguration)

View File

@ -1,5 +1,4 @@
using ConfusedPolarBear.Plugin.IntroSkipper.Providers;
using ConfusedPolarBear.Plugin.IntroSkipper.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;

View File

@ -6,8 +6,6 @@ using System.Threading;
using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Manager;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
@ -18,7 +16,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
/// </summary>
public class BaseItemAnalyzerTask
{
private readonly IReadOnlyCollection<MediaSegmentType> _modes;
private readonly IReadOnlyCollection<AnalysisMode> _analysisModes;
private readonly ILogger _logger;
@ -34,12 +32,12 @@ public class BaseItemAnalyzerTask
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
public BaseItemAnalyzerTask(
IReadOnlyCollection<MediaSegmentType> modes,
IReadOnlyCollection<AnalysisMode> modes,
ILogger logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_modes = modes;
_analysisModes = modes;
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
@ -81,7 +79,7 @@ public class BaseItemAnalyzerTask
queue = queue.Where(kvp => seasonsToAnalyze.Contains(kvp.Key)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
}
int totalQueued = queue.Sum(kvp => kvp.Value.Count) * _modes.Count;
int totalQueued = queue.Sum(kvp => kvp.Value.Count) * _analysisModes.Count;
if (totalQueued == 0)
{
throw new FingerprintException(
@ -107,7 +105,7 @@ public class BaseItemAnalyzerTask
// of the current media items were deleted from Jellyfin since the task was started.
var (episodes, requiredModes) = queueManager.VerifyQueue(
season.Value,
_modes.Where(m => !Plugin.Instance!.IsIgnored(season.Key, m)).ToList());
_analysisModes.Where(m => !Plugin.Instance!.IsIgnored(season.Key, m)).ToList());
if (episodes.Count == 0)
{
@ -122,13 +120,13 @@ public class BaseItemAnalyzerTask
first.SeriesName,
first.SeasonNumber);
Interlocked.Add(ref totalProcessed, episodes.Count * _modes.Count); // Update total Processed directly
Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly
progress.Report(totalProcessed * 100 / totalQueued);
return;
}
if (_modes.Count != requiredModes.Count)
if (_analysisModes.Count != requiredModes.Count)
{
Interlocked.Add(ref totalProcessed, episodes.Count);
progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed
@ -141,7 +139,7 @@ public class BaseItemAnalyzerTask
return;
}
foreach (MediaSegmentType mode in requiredModes)
foreach (AnalysisMode mode in requiredModes)
{
var analyzed = AnalyzeItems(episodes, mode, cancellationToken);
Interlocked.Add(ref totalProcessed, analyzed);
@ -183,7 +181,7 @@ public class BaseItemAnalyzerTask
/// <returns>Number of items that were successfully analyzed.</returns>
private int AnalyzeItems(
IReadOnlyList<QueuedEpisode> items,
MediaSegmentType mode,
AnalysisMode mode,
CancellationToken cancellationToken)
{
var totalItems = items.Count;
@ -218,7 +216,7 @@ public class BaseItemAnalyzerTask
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
}
if (mode == MediaSegmentType.Outro)
if (mode == AnalysisMode.Credits)
{
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
}

View File

@ -4,7 +4,6 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Manager;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@ -14,22 +13,29 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
/// <summary>
/// Analyze all television episodes for introduction sequences.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="CleanCacheTask"/> class.
/// </remarks>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public class CleanCacheTask(
ILogger<CleanCacheTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager) : IScheduledTask
public class CleanCacheTask : IScheduledTask
{
private readonly ILogger<CleanCacheTask> _logger = logger;
private readonly ILogger<CleanCacheTask> _logger;
private readonly ILoggerFactory _loggerFactory = loggerFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager = libraryManager;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="CleanCacheTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public CleanCacheTask(
ILogger<CleanCacheTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.

View File

@ -1,8 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@ -13,22 +14,29 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
/// 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>
public class DetectCreditsTask(
ILogger<DetectCreditsTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager) : IScheduledTask
public class DetectCreditsTask : IScheduledTask
{
private readonly ILogger<DetectCreditsTask> _logger = logger;
private readonly ILogger<DetectCreditsTask> _logger;
private readonly ILoggerFactory _loggerFactory = loggerFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager = libraryManager;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectCreditsTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public DetectCreditsTask(
ILogger<DetectCreditsTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
@ -74,7 +82,7 @@ public class DetectCreditsTask(
{
_logger.LogInformation("Scheduled Task is starting");
var modes = new List<MediaSegmentType> { MediaSegmentType.Outro };
var modes = new List<AnalysisMode> { AnalysisMode.Credits };
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
modes,

View File

@ -1,8 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@ -12,22 +13,29 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
/// <summary>
/// Analyze all television episodes for introduction sequences.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="DetectIntrosCreditsTask"/> class.
/// </remarks>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public class DetectIntrosCreditsTask(
ILogger<DetectIntrosCreditsTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager) : IScheduledTask
public class DetectIntrosCreditsTask : IScheduledTask
{
private readonly ILogger<DetectIntrosCreditsTask> _logger = logger;
private readonly ILogger<DetectIntrosCreditsTask> _logger;
private readonly ILoggerFactory _loggerFactory = loggerFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager = libraryManager;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectIntrosCreditsTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public DetectIntrosCreditsTask(
ILogger<DetectIntrosCreditsTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
@ -73,7 +81,7 @@ public class DetectIntrosCreditsTask(
{
_logger.LogInformation("Scheduled Task is starting");
var modes = new List<MediaSegmentType> { MediaSegmentType.Intro, MediaSegmentType.Outro };
var modes = new List<AnalysisMode> { AnalysisMode.Introduction, AnalysisMode.Credits };
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
modes,

View File

@ -1,8 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@ -12,22 +13,29 @@ namespace ConfusedPolarBear.Plugin.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>
public class DetectIntrosTask(
ILogger<DetectIntrosTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager) : IScheduledTask
public class DetectIntrosTask : IScheduledTask
{
private readonly ILogger<DetectIntrosTask> _logger = logger;
private readonly ILogger<DetectIntrosTask> _logger;
private readonly ILoggerFactory _loggerFactory = loggerFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager = libraryManager;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectIntrosTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public DetectIntrosTask(
ILogger<DetectIntrosTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
@ -73,7 +81,7 @@ public class DetectIntrosTask(
{
_logger.LogInformation("Scheduled Task is starting");
var modes = new List<MediaSegmentType> { MediaSegmentType.Intro };
var modes = new List<AnalysisMode> { AnalysisMode.Introduction };
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
modes,

View File

@ -15,216 +15,216 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Services
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Automatically skip past introduction sequences.
/// Commands clients to seek to the end of the intro as soon as they start playing it.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
/// </remarks>
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public class AutoSkip(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkip> logger) : IHostedService, IDisposable
{
/// <summary>
/// Automatically skip past introduction sequences.
/// Commands clients to seek to the end of the intro as soon as they start playing it.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
/// </remarks>
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public 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 HashSet<string> _clientList = [];
private void AutoSkipChanged(object? sender, BasePluginConfiguration e)
{
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 HashSet<string> _clientList = [];
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;
}
private void AutoSkipChanged(object? sender, BasePluginConfiguration e)
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)
{
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;
return;
}
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
{
var itemId = e.Item.Id;
var newState = false;
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
// Lookup the session for this item.
SessionInfo? session = null;
// Ignore all events except playback start & end
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
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;
}
// 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;
}
}
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
{
return;
}
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
// 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)
{
foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.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.
if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid)
{
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;
_logger.LogTrace(
"Playback position is {Position}, intro runs from {Start} to {End}",
position,
adjustedStart,
adjustedEnd);
if (position < adjustedStart || position > adjustedEnd)
{
continue;
}
// Notify the user that an introduction is being skipped for them.
var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText;
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;
}
}
newState = true;
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
// Reset the seek command state for this device.
lock (_sentSeekCommandLock)
{
Dispose(true);
GC.SuppressFinalize(this);
}
var device = session.DeviceId;
/// <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 skipping");
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
Plugin.Instance!.ConfigurationChanged += AutoSkipChanged;
// Make the timer restart automatically and set enabled to match the configuration value.
_playbackTimer.AutoReset = true;
_playbackTimer.Elapsed += PlaybackTimer_Elapsed;
AutoSkipChanged(null, Plugin.Instance.Configuration);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
return Task.CompletedTask;
_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.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.
if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid)
{
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;
_logger.LogTrace(
"Playback position is {Position}, intro runs from {Start} to {End}",
position,
adjustedStart,
adjustedEnd);
if (position < adjustedStart || position > adjustedEnd)
{
continue;
}
// Notify the user that an introduction is being skipped for them.
var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText;
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 skipping");
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
Plugin.Instance!.ConfigurationChanged += AutoSkipChanged;
// Make the timer restart automatically and set enabled to match the configuration value.
_playbackTimer.AutoReset = true;
_playbackTimer.Elapsed += PlaybackTimer_Elapsed;
AutoSkipChanged(null, Plugin.Instance.Configuration);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
return Task.CompletedTask;
}
}

View File

@ -15,216 +15,216 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Services
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <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
{
/// <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)
{
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 = [];
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 AutoSkipCreditChanged(object? sender, BasePluginConfiguration e)
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)
{
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;
return;
}
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
{
var itemId = e.Item.Id;
var newState = false;
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
// Lookup the session for this item.
SessionInfo? session = null;
// Ignore all events except playback start & end
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
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;
}
// 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;
}
}
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
{
return;
}
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
// 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)
{
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.
if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var credit) || !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;
}
}
newState = true;
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
// Reset the seek command state for this device.
lock (_sentSeekCommandLock)
{
Dispose(true);
GC.SuppressFinalize(this);
}
var device = session.DeviceId;
/// <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;
_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.
if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var credit) || !credit.Valid)
{
continue;
}
// Seek is unreliable if called at the very end of an episode.
var adjustedStart = credit.Start + Plugin.Instance.Configuration.SecondsOfCreditsStartToPlay;
var adjustedEnd = credit.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro;
_logger.LogTrace(
"Playback position is {Position}, credits run from {Start} to {End}",
position,
adjustedStart,
adjustedEnd);
if (position < adjustedStart || position > adjustedEnd)
{
continue;
}
// Notify the user that credits are being skipped for them.
var notificationText = Plugin.Instance!.Configuration.AutoSkipCreditsNotificationText;
if (!string.IsNullOrWhiteSpace(notificationText))
{
_sessionManager.SendMessageCommand(
session.Id,
session.Id,
new MessageCommand
{
Header = string.Empty, // some clients require header to be a string instead of null
Text = notificationText,
TimeoutMs = 2000,
},
CancellationToken.None);
}
_logger.LogDebug("Sending seek command to {Session}", deviceId);
_sessionManager.SendPlaystateCommand(
session.Id,
session.Id,
new PlaystateRequest
{
Command = PlaystateCommand.Seek,
ControllingUserId = session.UserId.ToString(),
SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond,
},
CancellationToken.None);
// Flag that we've sent the seek command so that it's not sent repeatedly
lock (_sentSeekCommandLock)
{
_logger.LogTrace("Setting seek command state for session {Session}", deviceId);
_sentSeekCommand[deviceId] = true;
}
}
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="disposing">Dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
_playbackTimer.Stop();
_playbackTimer.Dispose();
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Setting up automatic credit skipping");
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged;
// Make the timer restart automatically and set enabled to match the configuration value.
_playbackTimer.AutoReset = true;
_playbackTimer.Elapsed += PlaybackTimer_Elapsed;
AutoSkipCreditChanged(null, Plugin.Instance.Configuration);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
return Task.CompletedTask;
}
}

View File

@ -3,9 +3,8 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Manager;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
@ -262,18 +261,18 @@ public sealed class Entrypoint : IHostedService, IDisposable
_analyzeAgain = false;
var progress = new Progress<double>();
var modes = new List<MediaSegmentType>();
var modes = new List<AnalysisMode>();
var tasklogger = _loggerFactory.CreateLogger("DefaultLogger");
if (_config.AutoDetectIntros)
{
modes.Add(MediaSegmentType.Intro);
modes.Add(AnalysisMode.Introduction);
tasklogger = _loggerFactory.CreateLogger<DetectIntrosTask>();
}
if (_config.AutoDetectCredits)
{
modes.Add(MediaSegmentType.Outro);
modes.Add(AnalysisMode.Credits);
tasklogger = modes.Count == 2
? _loggerFactory.CreateLogger<DetectIntrosCreditsTask>()
: _loggerFactory.CreateLogger<DetectCreditsTask>();