Switch from XML Files to SQLite DB (#365)

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: Kilian von Pflugk <github@jumoog.io>
This commit is contained in:
rlauuzo 2024-11-02 18:17:22 +01:00 committed by GitHub
parent 29ca500ef1
commit a1d634b66e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 628 additions and 650 deletions

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Data; using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -26,7 +27,7 @@ public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFile
private readonly int _blackFrameMinimumPercentage = _config.BlackFrameMinimumPercentage; private readonly int _blackFrameMinimumPercentage = _config.BlackFrameMinimumPercentage;
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles( public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue, IReadOnlyList<QueuedEpisode> analysisQueue,
AnalysisMode mode, AnalysisMode mode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -46,7 +47,7 @@ public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFile
var searchDistance = 2 * _minimumCreditsDuration; var searchDistance = 2 * _minimumCreditsDuration;
foreach (var episode in episodeAnalysisQueue.Where(e => !e.State.IsAnalyzed(mode))) foreach (var episode in episodeAnalysisQueue.Where(e => !e.GetAnalyzed(mode)))
{ {
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
{ {
@ -116,13 +117,13 @@ public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFile
searchStart = episode.Duration - credit.Start + (0.5 * searchDistance); searchStart = episode.Duration - credit.Start + (0.5 * searchDistance);
creditTimes.Add(episode.EpisodeId, credit); creditTimes.Add(episode.EpisodeId, credit);
episode.State.SetAnalyzed(mode, true); episode.SetAnalyzed(mode, true);
} }
var analyzerHelper = new AnalyzerHelper(_logger); var analyzerHelper = new AnalyzerHelper(_logger);
creditTimes = analyzerHelper.AdjustIntroTimes(analysisQueue, creditTimes, mode); creditTimes = analyzerHelper.AdjustIntroTimes(analysisQueue, creditTimes, mode);
Plugin.Instance!.UpdateTimestamps(creditTimes, mode); await Plugin.Instance!.UpdateTimestamps(creditTimes, mode).ConfigureAwait(false);
return episodeAnalysisQueue; return episodeAnalysisQueue;
} }

View File

@ -7,6 +7,7 @@ using System.Globalization;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
@ -26,7 +27,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
private ILogger<ChapterAnalyzer> _logger = logger; private ILogger<ChapterAnalyzer> _logger = logger;
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles( public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue, IReadOnlyList<QueuedEpisode> analysisQueue,
AnalysisMode mode, AnalysisMode mode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -45,7 +46,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
return analysisQueue; return analysisQueue;
} }
foreach (var episode in episodeAnalysisQueue.Where(e => !e.State.IsAnalyzed(mode))) foreach (var episode in episodeAnalysisQueue.Where(e => !e.GetAnalyzed(mode)))
{ {
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
{ {
@ -64,10 +65,10 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
} }
skippableRanges.Add(episode.EpisodeId, skipRange); skippableRanges.Add(episode.EpisodeId, skipRange);
episode.State.SetAnalyzed(mode, true); episode.SetAnalyzed(mode, true);
} }
Plugin.Instance.UpdateTimestamps(skippableRanges, mode); await Plugin.Instance.UpdateTimestamps(skippableRanges, mode).ConfigureAwait(false);
return episodeAnalysisQueue; return episodeAnalysisQueue;
} }

View File

@ -7,6 +7,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Numerics; using System.Numerics;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Data; using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -52,7 +53,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
} }
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles( public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue, IReadOnlyList<QueuedEpisode> analysisQueue,
AnalysisMode mode, AnalysisMode mode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -67,7 +68,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue); var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue);
// Episodes that were analyzed and do not have an introduction. // Episodes that were analyzed and do not have an introduction.
var episodesWithoutIntros = episodeAnalysisQueue.Where(e => !e.State.IsAnalyzed(mode)).ToList(); var episodesWithoutIntros = episodeAnalysisQueue.Where(e => !e.GetAnalyzed(mode)).ToList();
_analysisMode = mode; _analysisMode = mode;
@ -79,7 +80,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
var episodesWithFingerprint = new List<QueuedEpisode>(episodesWithoutIntros); var episodesWithFingerprint = new List<QueuedEpisode>(episodesWithoutIntros);
// Load fingerprints from cache if available. // Load fingerprints from cache if available.
episodesWithFingerprint.AddRange(episodeAnalysisQueue.Where(e => e.State.IsAnalyzed(mode) && File.Exists(FFmpegWrapper.GetFingerprintCachePath(e, mode)))); episodesWithFingerprint.AddRange(episodeAnalysisQueue.Where(e => e.GetAnalyzed(mode) && File.Exists(FFmpegWrapper.GetFingerprintCachePath(e, mode))));
// Ensure at least two fingerprints are present. // Ensure at least two fingerprints are present.
if (episodesWithFingerprint.Count == 1) if (episodesWithFingerprint.Count == 1)
@ -89,7 +90,9 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
.Where((episode, index) => Math.Abs(index - indexInAnalysisQueue) <= 1 && index != indexInAnalysisQueue)); .Where((episode, index) => Math.Abs(index - indexInAnalysisQueue) <= 1 && index != indexInAnalysisQueue));
} }
seasonIntros = episodesWithFingerprint.Where(e => e.State.IsAnalyzed(mode)).ToDictionary(e => e.EpisodeId, e => Plugin.GetIntroByMode(e.EpisodeId, mode)); seasonIntros = episodesWithFingerprint
.Where(e => e.GetAnalyzed(mode))
.ToDictionary(e => e.EpisodeId, e => Plugin.Instance!.GetSegmentByMode(e.EpisodeId, mode));
// Compute fingerprints for all episodes in the season // Compute fingerprints for all episodes in the season
foreach (var episode in episodesWithFingerprint) foreach (var episode in episodesWithFingerprint)
@ -193,7 +196,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
if (seasonIntros.ContainsKey(currentEpisode.EpisodeId)) if (seasonIntros.ContainsKey(currentEpisode.EpisodeId))
{ {
episodesWithFingerprint.Add(currentEpisode); episodesWithFingerprint.Add(currentEpisode);
episodeAnalysisQueue.FirstOrDefault(x => x.EpisodeId == currentEpisode.EpisodeId)?.State.SetAnalyzed(mode, true); episodeAnalysisQueue.FirstOrDefault(x => x.EpisodeId == currentEpisode.EpisodeId)?.SetAnalyzed(mode, true);
} }
} }
@ -207,7 +210,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
var analyzerHelper = new AnalyzerHelper(_logger); var analyzerHelper = new AnalyzerHelper(_logger);
seasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, seasonIntros, _analysisMode); seasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, seasonIntros, _analysisMode);
Plugin.Instance!.UpdateTimestamps(seasonIntros, _analysisMode); await Plugin.Instance!.UpdateTimestamps(seasonIntros, _analysisMode).ConfigureAwait(false);
return episodeAnalysisQueue; return episodeAnalysisQueue;
} }

View File

@ -3,6 +3,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Data; using IntroSkipper.Data;
namespace IntroSkipper.Analyzers; namespace IntroSkipper.Analyzers;
@ -19,7 +20,7 @@ public interface IMediaFileAnalyzer
/// <param name="mode">Analysis mode.</param> /// <param name="mode">Analysis mode.</param>
/// <param name="cancellationToken">Cancellation token from scheduled task.</param> /// <param name="cancellationToken">Cancellation token from scheduled task.</param>
/// <returns>Collection of media files that were **unsuccessfully analyzed**.</returns> /// <returns>Collection of media files that were **unsuccessfully analyzed**.</returns>
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles( Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue, IReadOnlyList<QueuedEpisode> analysisQueue,
AnalysisMode mode, AnalysisMode mode,
CancellationToken cancellationToken); CancellationToken cancellationToken);

View File

@ -103,9 +103,9 @@
<summary>Modify Segment Parameters</summary> <summary>Modify Segment Parameters</summary>
<p> <p>
<b style="color:orange">Changing segment parameters requires regenerating media segments before changes take effect.</b> <b style="color: orange">Changing segment parameters requires regenerating media segments before changes take effect.</b>
<br /> <br />
Per the jellyfin MediaSegments API, records must be updated individually and may be slow to regenerate. Per the jellyfin MediaSegments API, records must be updated individually and may be slow to regenerate.
</p> </p>
<div class="inputContainer"> <div class="inputContainer">
@ -242,9 +242,7 @@
</div> </div>
</details> </details>
<p align="center" style="font-size: 0.75em"> <p align="center" style="font-size: 0.75em">EDL file generation has been removed. Please use endrl's <a href="https://github.com/endrl/jellyfin-plugin-edl">EDL plugin</a>.</p>
EDL file generation has been removed. Please use endrl's <a href="https://github.com/endrl/jellyfin-plugin-edl">EDL plugin</a>.
</p>
</fieldset> </fieldset>
<fieldset class="verticalSection-extrabottompadding"> <fieldset class="verticalSection-extrabottompadding">
@ -324,9 +322,7 @@
</div> </div>
</div> </div>
<div id="warningMessage" style="color: #721c24; background-color: #f7cf1f; border: 1px solid #f5c6cb; border-radius: 4px; padding: 10px; margin-bottom: 10px"> <div id="warningMessage" style="color: #721c24; background-color: #f7cf1f; border: 1px solid #f5c6cb; border-radius: 4px; padding: 10px; margin-bottom: 10px">Failed to add skip button to web interface. See <a href="https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible" target="_blank" rel="noopener noreferrer">troubleshooting guide</a> for the most common issues.</div>
Failed to add skip button to web interface. See <a href="https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible" target="_blank" rel="noopener noreferrer">troubleshooting guide</a> for the most common issues.
</div>
<details> <details>
<summary>User Interface Customization</summary> <summary>User Interface Customization</summary>
@ -409,28 +405,39 @@
</div> </div>
<br /> <br />
<div id="ignorelistSection" style="display: none"> <div id="analyzerActionsSection" style="display: none">
<h3 style="margin: 0">Ignore list editor</h3> <h3 style="margin: 0">Analyzer actions</h3>
<p style="margin: 0"> <p style="margin: 0">
Add or remove items from the ignore list. Items on the ignore list will not be analyzed.<br /> Choose how segments should be analyzed for this season.<br />
You can apply the changes for the entire series or just the selected season. Default uses all available detection methods (Chromaprint, Chapter, and BlackFrame for credits).<br />
Select specific methods to limit analysis, or None to skip detection entirely.
</p> </p>
<br /> <br />
<div id="ignoreListCheckboxContainer"> <div id="analyzerActionsContainer">
<label for="ignorelistIntro" style="margin-right: 1.5em; display: inline-block"> <label for="actionIntro" style="margin-right: 1.5em; display: inline-block">
<span>Ignore intros</span> <span>Introduction analysis</span>
<input type="checkbox" id="ignorelistIntro" /> <select is="emby-select" id="actionIntro" class="emby-select-withcolor emby-select">
<option value="Default">Default</option>
<option value="Chapter">Chapter</option>
<option value="Chromaprint">Chromaprint</option>
<option value="None">None</option>
</select>
</label> </label>
<label for="ignorelistCredits" style="margin-right: 1.5em; display: inline-block"> <label for="actionCredits" style="margin-right: 1.5em; display: inline-block">
<span>Ignore credits</span> <span>Credits analysis</span>
<input type="checkbox" id="ignorelistCredits" /> <select is="emby-select" id="actionCredits" class="emby-select-withcolor emby-select">
<option value="Default">Default</option>
<option value="Chapter">Chapter</option>
<option value="Chromaprint">Chromaprint</option>
<option value="BlackFrame">BlackFrame</option>
<option value="None">None</option>
</select>
</label> </label>
</div> </div>
<br /> <br />
<button is="emby-button" id="saveIgnoreListSeries" class="raised button-submit block emby-button">Apply to series / movie</button> <button is="emby-button" id="saveAnalyzerActions" class="raised button-submit block emby-button" style="display: none">Apply changes.</button>
<button is="emby-button" id="saveIgnoreListSeason" class="raised button-submit block emby-button" style="display: none">Apply to season</button>
</div> </div>
<br /> <br />
@ -513,7 +520,6 @@
</div> </div>
<div id="fingerprintVisualizer" style="display: none"> <div id="fingerprintVisualizer" style="display: none">
<h3>Fingerprint Visualizer</h3> <h3>Fingerprint Visualizer</h3>
<p> <p>
Interactively compare the audio fingerprints of two episodes. <br /> Interactively compare the audio fingerprints of two episodes. <br />
@ -624,8 +630,6 @@
// seasons grouped by show // seasons grouped by show
var shows = {}; var shows = {};
var IgnoreListSeasonId;
// settings elements // settings elements
var visualizer = document.querySelector("details#visualizer"); var visualizer = document.querySelector("details#visualizer");
var support = document.querySelector("details#support"); var support = document.querySelector("details#support");
@ -667,11 +671,10 @@
var booleanConfigurationFields = ["AutoDetectIntros", "AutoDetectCredits", "AnalyzeMovies", "AnalyzeSeasonZero", "SelectAllLibraries", "UpdateMediaSegments", "RegenerateMediaSegments", "CacheFingerprints", "AutoSkip", "AutoSkipCredits", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonEnabled"]; var booleanConfigurationFields = ["AutoDetectIntros", "AutoDetectCredits", "AnalyzeMovies", "AnalyzeSeasonZero", "SelectAllLibraries", "UpdateMediaSegments", "RegenerateMediaSegments", "CacheFingerprints", "AutoSkip", "AutoSkipCredits", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonEnabled"];
// visualizer elements // visualizer elements
var ignorelistSection = document.querySelector("div#ignorelistSection"); var analyzerActionsSection = document.querySelector("div#analyzerActionsSection");
var ignorelistIntro = ignorelistSection.querySelector("input#ignorelistIntro"); var actionIntro = analyzerActionsSection.querySelector("select#actionIntro");
var ignorelistCredits = ignorelistSection.querySelector("input#ignorelistCredits"); var actionCredits = analyzerActionsSection.querySelector("select#actionCredits");
var saveIgnoreListSeasonButton = ignorelistSection.querySelector("button#saveIgnoreListSeason"); var saveAnalyzerActionsButton = analyzerActionsSection.querySelector("button#saveAnalyzerActions");
var saveIgnoreListSeriesButton = ignorelistSection.querySelector("button#saveIgnoreListSeries");
var canvas = document.querySelector("canvas#troubleshooter"); var canvas = document.querySelector("canvas#troubleshooter");
var selectShow = document.querySelector("select#troubleshooterShow"); var selectShow = document.querySelector("select#troubleshooterShow");
var seasonSelection = document.getElementById("seasonSelection"); var seasonSelection = document.getElementById("seasonSelection");
@ -852,8 +855,8 @@
// when the fingerprint visualizer opens, populate show names // when the fingerprint visualizer opens, populate show names
async function visualizerToggled() { async function visualizerToggled() {
if (!visualizer.open) { if (!visualizer.open) {
ignorelistSection.style.display = "none"; analyzerActionsSection.style.display = "none";
saveIgnoreListSeasonButton.style.display = "none"; saveAnalyzerActionsButton.style.display = "none";
return; return;
} }
@ -948,22 +951,11 @@
clearSelect(selectEpisode1); clearSelect(selectEpisode1);
clearSelect(selectEpisode2); clearSelect(selectEpisode2);
// show the ignore list editor.
Dashboard.showLoadingMsg();
const IgnoreList = await getJson("Intros/IgnoreListSeries/" + encodeURI(selectShow.value));
ignorelistIntro.checked = IgnoreList.IgnoreIntro;
ignorelistCredits.checked = IgnoreList.IgnoreCredits;
ignorelistSection.style.display = "unset";
saveIgnoreListSeasonButton.style.display = "none";
Dashboard.hideLoadingMsg();
if (shows[selectShow.value].IsMovie) { if (shows[selectShow.value].IsMovie) {
movieLoaded(); movieLoaded();
return; return;
} }
saveIgnoreListSeriesButton.textContent = "Apply to series";
// add all seasons from this show to the season select // add all seasons from this show to the season select
for (const season in shows[selectShow.value].Seasons) { for (const season in shows[selectShow.value].Seasons) {
addItem(selectSeason, "Season " + shows[selectShow.value].Seasons[season], season); addItem(selectSeason, "Season " + shows[selectShow.value].Seasons[season], season);
@ -976,12 +968,12 @@
async function seasonChanged() { async function seasonChanged() {
const seasonData = encodeURI(selectShow.value) + "/" + encodeURI(selectSeason.value); const seasonData = encodeURI(selectShow.value) + "/" + encodeURI(selectSeason.value);
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
// show the ignore list editor. // show the analyzer actions editor.
saveIgnoreListSeasonButton.style.display = "block"; saveAnalyzerActionsButton.style.display = "block";
const IgnoreList = await getJson("Intros/IgnoreListSeason/" + encodeURI(selectSeason.value)); const analyzerActions = (await getJson("Intros/AnalyzerActions/" + encodeURI(selectSeason.value))) || { Introduction: "Default", Credits: "Default" };
ignorelistIntro.checked = IgnoreList.IgnoreIntro; actionIntro.value = analyzerActions.Introduction || "Default";
ignorelistCredits.checked = IgnoreList.IgnoreCredits; actionCredits.value = analyzerActions.Credits || "Default";
IgnoreListSeasonId = IgnoreList.SeasonId; analyzerActionsSection.style.display = "unset";
// show the erase season button // show the erase season button
eraseSeasonContainer.style.display = "unset"; eraseSeasonContainer.style.display = "unset";
@ -1049,10 +1041,9 @@
} }
async function movieLoaded() { async function movieLoaded() {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
saveIgnoreListSeriesButton.textContent = "Apply to movie"; saveAnalyzerActionsButton.textContent = "Apply to movie";
seasonSelection.style.display = "none"; seasonSelection.style.display = "none";
episodeSelection.style.display = "none"; episodeSelection.style.display = "none";
eraseMovieContainer.style.display = "unset"; eraseMovieContainer.style.display = "unset";
@ -1088,7 +1079,7 @@
document.querySelector("#editLeftIntroEpisodeStartEdit").value = leftEpisodeJson.Introduction.Start; document.querySelector("#editLeftIntroEpisodeStartEdit").value = leftEpisodeJson.Introduction.Start;
document.querySelector("#editLeftIntroEpisodeEndEdit").value = leftEpisodeJson.Introduction.End; document.querySelector("#editLeftIntroEpisodeEndEdit").value = leftEpisodeJson.Introduction.End;
document.querySelector("#editLeftCreditEpisodeStartEdit").value = leftEpisodeJson.Credits.Start; document.querySelector("#editLeftCreditEpisodeStartEdit").value = leftEpisodeJson.Credits.Start;
document.querySelector("#editLeftCreditEpisodeEndEdit").value = leftEpisodeJson.Credits.End document.querySelector("#editLeftCreditEpisodeEndEdit").value = leftEpisodeJson.Credits.End;
// Update display inputs // Update display inputs
const inputs = document.querySelectorAll('#timestampEditor input[type="number"]'); const inputs = document.querySelectorAll('#timestampEditor input[type="number"]');
@ -1412,33 +1403,21 @@
document.getElementById("eraseMovieCacheCheckbox").checked = false; document.getElementById("eraseMovieCacheCheckbox").checked = false;
}); });
}); });
saveIgnoreListSeasonButton.addEventListener("click", () => { saveAnalyzerActionsButton.addEventListener("click", () => {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
var url = "Intros/IgnoreList/UpdateSeason"; var url = "Intros/AnalyzerActions/UpdateSeason";
const newRhs = { const actions = {
IgnoreIntro: ignorelistIntro.checked, id: selectSeason.value,
IgnoreCredits: ignorelistCredits.checked, analyzerActions: {
SeasonId: IgnoreListSeasonId, Introduction: actionIntro.value,
Credits: actionCredits.value,
},
}; };
fetchWithAuth(url, "POST", JSON.stringify(newRhs)); fetchWithAuth(url, "POST", JSON.stringify(actions));
Dashboard.alert("Ignore list updated for " + selectSeason.value + " of " + selectShow.value); Dashboard.alert("Analyzer actions updated for " + selectSeason.value + " of " + selectShow.value);
Dashboard.hideLoadingMsg();
});
saveIgnoreListSeriesButton.addEventListener("click", () => {
Dashboard.showLoadingMsg();
var url = "Intros/IgnoreList/UpdateSeries" + "/" + encodeURIComponent(selectShow.value);
const newRhs = {
IgnoreIntro: ignorelistIntro.checked,
IgnoreCredits: ignorelistCredits.checked,
};
fetchWithAuth(url, "POST", JSON.stringify(newRhs));
Dashboard.alert("Ignore list updated for " + selectShow.value);
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
}); });
btnUpdateTimestamps.addEventListener("click", () => { btnUpdateTimestamps.addEventListener("click", () => {

View File

@ -9,12 +9,14 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Data; using IntroSkipper.Data;
using IntroSkipper.Db;
using IntroSkipper.Manager; using IntroSkipper.Manager;
using MediaBrowser.Common.Api; using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace IntroSkipper.Controllers; namespace IntroSkipper.Controllers;
@ -42,9 +44,8 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
[FromRoute] Guid id, [FromRoute] Guid id,
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction) [FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
{ {
var intro = GetIntro(id, mode); var intros = GetIntros(id);
if (!intros.TryGetValue(mode, out var intro))
if (intro is null || !intro.Valid)
{ {
return NotFound(); return NotFound();
} }
@ -67,7 +68,7 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
{ {
// only update existing episodes // only update existing episodes
var rawItem = Plugin.Instance!.GetItem(id); var rawItem = Plugin.Instance!.GetItem(id);
if (rawItem == null || rawItem is not Episode and not Movie) if (rawItem is not Episode and not Movie)
{ {
return NotFound(); return NotFound();
} }
@ -75,18 +76,15 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
if (timestamps?.Introduction.End > 0.0) if (timestamps?.Introduction.End > 0.0)
{ {
var tr = new TimeRange(timestamps.Introduction.Start, timestamps.Introduction.End); var tr = new TimeRange(timestamps.Introduction.Start, timestamps.Introduction.End);
Plugin.Instance!.Intros[id] = new Segment(id, tr); await Plugin.Instance!.UpdateTimestamps(new Dictionary<Guid, Segment> { [id] = new Segment(id, tr) }, AnalysisMode.Introduction).ConfigureAwait(false);
} }
if (timestamps?.Credits.End > 0.0) if (timestamps?.Credits.End > 0.0)
{ {
var cr = new TimeRange(timestamps.Credits.Start, timestamps.Credits.End); var tr = new TimeRange(timestamps.Credits.Start, timestamps.Credits.End);
Plugin.Instance!.Credits[id] = new Segment(id, cr); await Plugin.Instance!.UpdateTimestamps(new Dictionary<Guid, Segment> { [id] = new Segment(id, tr) }, AnalysisMode.Credits).ConfigureAwait(false);
} }
Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction);
Plugin.Instance!.SaveTimestamps(AnalysisMode.Credits);
if (Plugin.Instance.Configuration.UpdateMediaSegments) if (Plugin.Instance.Configuration.UpdateMediaSegments)
{ {
var episode = Plugin.Instance!.QueuedMediaItems[rawItem is Episode e ? e.SeasonId : rawItem.Id] var episode = Plugin.Instance!.QueuedMediaItems[rawItem is Episode e ? e.SeasonId : rawItem.Id]
@ -114,20 +112,22 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
{ {
// only get return content for episodes // only get return content for episodes
var rawItem = Plugin.Instance!.GetItem(id); var rawItem = Plugin.Instance!.GetItem(id);
if (rawItem == null || rawItem is not Episode and not Movie) if (rawItem is not Episode and not Movie)
{ {
return NotFound(); return NotFound();
} }
var times = new TimeStamps(); var times = new TimeStamps();
if (Plugin.Instance!.Intros.TryGetValue(id, out var introValue)) var segments = Plugin.Instance!.GetSegmentsById(id);
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
{ {
times.Introduction = introValue; times.Introduction = introSegment;
} }
if (Plugin.Instance!.Credits.TryGetValue(id, out var creditValue)) if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment))
{ {
times.Credits = creditValue; times.Credits = creditSegment;
} }
return times; return times;
@ -142,16 +142,16 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
[HttpGet("Episode/{id}/IntroSkipperSegments")] [HttpGet("Episode/{id}/IntroSkipperSegments")]
public ActionResult<Dictionary<AnalysisMode, Intro>> GetSkippableSegments([FromRoute] Guid id) public ActionResult<Dictionary<AnalysisMode, Intro>> GetSkippableSegments([FromRoute] Guid id)
{ {
var segments = new Dictionary<AnalysisMode, Intro>(); var segments = GetIntros(id);
if (GetIntro(id, AnalysisMode.Introduction) is Intro intro) if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
{ {
segments[AnalysisMode.Introduction] = intro; segments[AnalysisMode.Introduction] = introSegment;
} }
if (GetIntro(id, AnalysisMode.Credits) is Intro credits) if (segments.TryGetValue(AnalysisMode.Introduction, out var creditSegment))
{ {
segments[AnalysisMode.Credits] = credits; segments[AnalysisMode.Credits] = creditSegment;
} }
return segments; return segments;
@ -159,41 +159,47 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
/// <summary>Lookup and return the skippable timestamps for the provided item.</summary> /// <summary>Lookup and return the skippable timestamps for the provided item.</summary>
/// <param name="id">Unique identifier of this episode.</param> /// <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> /// <returns>Intro object if the provided item has an intro, null otherwise.</returns>
private static Intro? GetIntro(Guid id, AnalysisMode mode) private static Dictionary<AnalysisMode, Intro> GetIntros(Guid id)
{ {
try var timestamps = Plugin.Instance!.GetSegmentsById(id);
var intros = new Dictionary<AnalysisMode, Intro>();
foreach (var (mode, timestamp) in timestamps)
{ {
var timestamp = Plugin.GetIntroByMode(id, mode); if (!timestamp.Valid)
{
continue;
}
// Operate on a copy to avoid mutating the original Intro object stored in the dictionary. // Create new Intro to avoid mutating the original stored in dictionary
var segment = new Intro(timestamp); var segment = new Intro(timestamp);
var config = Plugin.Instance.Configuration;
var config = Plugin.Instance!.Configuration; // Calculate intro end time based on mode
segment.IntroEnd = mode == AnalysisMode.Credits segment.IntroEnd = mode == AnalysisMode.Credits
? GetAdjustedIntroEnd(id, segment.IntroEnd, config) ? GetAdjustedIntroEnd(id, segment.IntroEnd, config)
: segment.IntroEnd - config.RemainingSecondsOfIntro; : segment.IntroEnd - config.RemainingSecondsOfIntro;
// Set skip button prompt visibility times
const double MIN_REMAINING_TIME = 3.0; // Minimum seconds before end to hide prompt
if (config.PersistSkipButton) if (config.PersistSkipButton)
{ {
segment.ShowSkipPromptAt = segment.IntroStart; segment.ShowSkipPromptAt = segment.IntroStart;
segment.HideSkipPromptAt = segment.IntroEnd - 3; segment.HideSkipPromptAt = segment.IntroEnd - MIN_REMAINING_TIME;
} }
else else
{ {
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment); segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
segment.HideSkipPromptAt = Math.Min( segment.HideSkipPromptAt = Math.Min(
segment.IntroStart + config.HidePromptAdjustment, segment.IntroStart + config.HidePromptAdjustment,
segment.IntroEnd - 3); segment.IntroEnd - MIN_REMAINING_TIME);
} }
return segment; intros[mode] = segment;
}
catch (KeyNotFoundException)
{
return null;
} }
return intros;
} }
private static double GetAdjustedIntroEnd(Guid id, double segmentEnd, PluginConfiguration config) private static double GetAdjustedIntroEnd(Guid id, double segmentEnd, PluginConfiguration config)
@ -213,24 +219,22 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
/// <returns>No content.</returns> /// <returns>No content.</returns>
[Authorize(Policy = Policies.RequiresElevation)] [Authorize(Policy = Policies.RequiresElevation)]
[HttpPost("Intros/EraseTimestamps")] [HttpPost("Intros/EraseTimestamps")]
public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false) public async Task<ActionResult> ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false)
{ {
if (mode == AnalysisMode.Introduction) using var db = new IntroSkipperDbContext(Plugin.Instance!.DbPath);
{ var segments = await db.DbSegment
Plugin.Instance!.Intros.Clear(); .Where(s => s.Type == mode)
} .ToListAsync()
else if (mode == AnalysisMode.Credits) .ConfigureAwait(false);
{
Plugin.Instance!.Credits.Clear(); db.DbSegment.RemoveRange(segments);
} await db.SaveChangesAsync().ConfigureAwait(false);
if (eraseCache) if (eraseCache)
{ {
FFmpegWrapper.DeleteCacheFiles(mode); FFmpegWrapper.DeleteCacheFiles(mode);
} }
Plugin.Instance!.EpisodeStates.Clear();
Plugin.Instance!.SaveTimestamps(mode);
return NoContent(); return NoContent();
} }

View File

@ -9,6 +9,7 @@ using System.Net.Mime;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IntroSkipper.Data; using IntroSkipper.Data;
using IntroSkipper.Db;
using IntroSkipper.Manager; using IntroSkipper.Manager;
using MediaBrowser.Common.Api; using MediaBrowser.Common.Api;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -83,49 +84,19 @@ public class VisualizationController(ILogger<VisualizationController> logger, Me
} }
/// <summary> /// <summary>
/// Returns the ignore list for the provided season. /// Returns the analyzer actions for the provided season.
/// </summary> /// </summary>
/// <param name="seasonId">Season ID.</param> /// <param name="seasonId">Season ID.</param>
/// <returns>List of episode titles.</returns> /// <returns>List of episode titles.</returns>
[HttpGet("IgnoreListSeason/{SeasonId}")] [HttpGet("AnalyzerActions/{SeasonId}")]
public ActionResult<IgnoreListItem> GetIgnoreListSeason([FromRoute] Guid seasonId) public ActionResult<IReadOnlyDictionary<AnalysisMode, AnalyzerAction>> GetAnalyzerAction([FromRoute] Guid seasonId)
{ {
if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(seasonId)) if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(seasonId))
{ {
return NotFound(); return NotFound();
} }
if (!Plugin.Instance!.IgnoreList.TryGetValue(seasonId, out _)) return Ok(Plugin.Instance!.GetAnalyzerAction(seasonId));
{
return new IgnoreListItem(seasonId);
}
return new IgnoreListItem(Plugin.Instance!.IgnoreList[seasonId]);
}
/// <summary>
/// Returns the ignore list for the provided series.
/// </summary>
/// <param name="seriesId">Show ID.</param>
/// <returns>List of episode titles.</returns>
[HttpGet("IgnoreListSeries/{SeriesId}")]
public ActionResult<IgnoreListItem> GetIgnoreListSeries([FromRoute] Guid seriesId)
{
var seasonIds = Plugin.Instance!.QueuedMediaItems
.Where(kvp => kvp.Value.Any(e => e.SeriesId == seriesId))
.Select(kvp => kvp.Key)
.ToList();
if (seasonIds.Count == 0)
{
return NotFound();
}
return new IgnoreListItem(Guid.Empty)
{
IgnoreIntro = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, AnalysisMode.Introduction)),
IgnoreCredits = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, AnalysisMode.Credits))
};
} }
/// <summary> /// <summary>
@ -199,13 +170,22 @@ public class VisualizationController(ILogger<VisualizationController> logger, Me
try try
{ {
using var db = new IntroSkipperDbContext(Plugin.Instance!.DbPath);
foreach (var episode in episodes) foreach (var episode in episodes)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var segments = Plugin.Instance!.GetSegmentsById(episode.EpisodeId);
Plugin.Instance.Intros.TryRemove(episode.EpisodeId, out _); if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
Plugin.Instance.Credits.TryRemove(episode.EpisodeId, out _); {
episode.State.ResetStates(); db.DbSegment.Remove(new DbSegment(introSegment, AnalysisMode.Introduction));
}
if (segments.TryGetValue(AnalysisMode.Introduction, out var creditSegment))
{
db.DbSegment.Remove(new DbSegment(creditSegment, AnalysisMode.Credits));
}
if (eraseCache) if (eraseCache)
{ {
@ -213,7 +193,7 @@ public class VisualizationController(ILogger<VisualizationController> logger, Me
} }
} }
Plugin.Instance.SaveTimestamps(AnalysisMode.Introduction | AnalysisMode.Credits); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
if (Plugin.Instance.Configuration.UpdateMediaSegments) if (Plugin.Instance.Configuration.UpdateMediaSegments)
{ {
@ -229,82 +209,14 @@ public class VisualizationController(ILogger<VisualizationController> logger, Me
} }
/// <summary> /// <summary>
/// Updates the ignore list for the provided season. /// Updates the analyzer actions for the provided season.
/// </summary> /// </summary>
/// <param name="ignoreListItem">New ignore list items.</param> /// <param name="request">Update analyzer actions request.</param>
/// <param name="save">Save the ignore list.</param>
/// <returns>No content.</returns> /// <returns>No content.</returns>
[HttpPost("IgnoreList/UpdateSeason")] [HttpPost("AnalyzerActions/UpdateSeason")]
public ActionResult UpdateIgnoreListSeason([FromBody] IgnoreListItem ignoreListItem, bool save = true) public async Task<ActionResult> UpdateAnalyzerActions([FromBody] UpdateAnalyzerActionsRequest request)
{ {
if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(ignoreListItem.SeasonId)) await Plugin.Instance!.UpdateAnalyzerActionAsync(request.Id, request.AnalyzerActions).ConfigureAwait(false);
{
return NotFound();
}
if (ignoreListItem.IgnoreIntro || ignoreListItem.IgnoreCredits)
{
Plugin.Instance!.IgnoreList.AddOrUpdate(ignoreListItem.SeasonId, ignoreListItem, (_, _) => ignoreListItem);
}
else
{
Plugin.Instance!.IgnoreList.TryRemove(ignoreListItem.SeasonId, out _);
}
if (save)
{
Plugin.Instance!.SaveIgnoreList();
}
return NoContent();
}
/// <summary>
/// Updates the ignore list for the provided series.
/// </summary>
/// <param name="seriesId">Series ID.</param>
/// <param name="ignoreListItem">New ignore list items.</param>
/// <returns>No content.</returns>
[HttpPost("IgnoreList/UpdateSeries/{SeriesId}")]
public ActionResult UpdateIgnoreListSeries([FromRoute] Guid seriesId, [FromBody] IgnoreListItem ignoreListItem)
{
var seasonIds = Plugin.Instance!.QueuedMediaItems
.Where(kvp => kvp.Value.Any(e => e.SeriesId == seriesId))
.Select(kvp => kvp.Key)
.ToList();
if (seasonIds.Count == 0)
{
return NotFound();
}
foreach (var seasonId in seasonIds)
{
UpdateIgnoreListSeason(new IgnoreListItem(ignoreListItem) { SeasonId = seasonId }, false);
}
Plugin.Instance!.SaveIgnoreList();
return NoContent();
}
/// <summary>
/// Updates the introduction timestamps for the provided episode.
/// </summary>
/// <param name="id">Episode ID to update timestamps for.</param>
/// <param name="timestamps">New introduction start and end times.</param>
/// <response code="204">New introduction timestamps saved.</response>
/// <returns>No content.</returns>
[HttpPost("Episode/{Id}/UpdateIntroTimestamps")]
[Obsolete("deprecated use Episode/{Id}/Timestamps")]
public ActionResult UpdateIntroTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps)
{
if (timestamps.IntroEnd > 0.0)
{
var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd);
Plugin.Instance!.Intros[id] = new Segment(id, tr);
Plugin.Instance.SaveTimestamps(AnalysisMode.Introduction);
}
return NoContent(); return NoContent();
} }

View File

@ -0,0 +1,35 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Data;
/// <summary>
/// Type of media file analysis to perform.
/// </summary>
public enum AnalyzerAction
{
/// <summary>
/// Default action.
/// </summary>
Default,
/// <summary>
/// Detect chapters.
/// </summary>
Chapter,
/// <summary>
/// Detect chromaprint fingerprints.
/// </summary>
Chromaprint,
/// <summary>
/// Detect black frames.
/// </summary>
BlackFrame,
/// <summary>
/// No action.
/// </summary>
None,
}

View File

@ -1,53 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
namespace IntroSkipper.Data;
/// <summary>
/// Represents the state of an episode regarding analysis and blacklist status.
/// </summary>
public class EpisodeState
{
private readonly bool[] _analyzedStates = new bool[2];
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(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(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(AnalysisMode mode, bool value) => _blacklistedStates[(int)mode] = value;
/// <summary>
/// Resets the analyzed states.
/// </summary>
public void ResetStates()
{
Array.Clear(_analyzedStates);
Array.Clear(_blacklistedStates);
}
}

View File

@ -1,92 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Runtime.Serialization;
namespace IntroSkipper.Data;
/// <summary>
/// Represents an item to ignore.
/// </summary>
[DataContract(Namespace = "http://schemas.datacontract.org/2004/07/ConfusedPolarBear.Plugin.IntroSkipper")]
public class IgnoreListItem
{
/// <summary>
/// Initializes a new instance of the <see cref="IgnoreListItem"/> class.
/// </summary>
public IgnoreListItem()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="IgnoreListItem"/> class.
/// </summary>
/// <param name="seasonId">The season id.</param>
public IgnoreListItem(Guid seasonId)
{
SeasonId = seasonId;
}
/// <summary>
/// Initializes a new instance of the <see cref="IgnoreListItem"/> class.
/// </summary>
/// <param name="item">The item to copy.</param>
public IgnoreListItem(IgnoreListItem item)
{
SeasonId = item.SeasonId;
IgnoreIntro = item.IgnoreIntro;
IgnoreCredits = item.IgnoreCredits;
}
/// <summary>
/// Gets or sets the season id.
/// </summary>
[DataMember]
public Guid SeasonId { get; set; } = Guid.Empty;
/// <summary>
/// Gets or sets a value indicating whether to ignore the intro.
/// </summary>
[DataMember]
public bool IgnoreIntro { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether to ignore the credits.
/// </summary>
[DataMember]
public bool IgnoreCredits { get; set; } = false;
/// <summary>
/// Toggles the provided mode to the provided value.
/// </summary>
/// <param name="mode">Analysis mode.</param>
/// <param name="value">Value to set.</param>
public void Toggle(AnalysisMode mode, bool value)
{
switch (mode)
{
case AnalysisMode.Introduction:
IgnoreIntro = value;
break;
case AnalysisMode.Credits:
IgnoreCredits = value;
break;
}
}
/// <summary>
/// Checks if the provided mode is ignored.
/// </summary>
/// <param name="mode">Analysis mode.</param>
/// <returns>True if ignored, false otherwise.</returns>
public bool IsIgnored(AnalysisMode mode)
{
return mode switch
{
AnalysisMode.Introduction => IgnoreIntro,
AnalysisMode.Credits => IgnoreCredits,
_ => false,
};
}
}

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic;
namespace IntroSkipper.Data; namespace IntroSkipper.Data;
@ -10,6 +11,8 @@ namespace IntroSkipper.Data;
/// </summary> /// </summary>
public class QueuedEpisode public class QueuedEpisode
{ {
private readonly Dictionary<AnalysisMode, bool> _isAnalyzed = [];
/// <summary> /// <summary>
/// Gets or sets the series name. /// Gets or sets the series name.
/// </summary> /// </summary>
@ -31,9 +34,9 @@ public class QueuedEpisode
public Guid SeriesId { get; set; } public Guid SeriesId { get; set; }
/// <summary> /// <summary>
/// Gets the state of the episode. /// Gets a value indicating whether this media has been already analyzed.
/// </summary> /// </summary>
public EpisodeState State => Plugin.Instance!.GetState(EpisodeId); public IReadOnlyDictionary<AnalysisMode, bool> IsAnalyzed => _isAnalyzed;
/// <summary> /// <summary>
/// Gets or sets the full path to episode. /// Gets or sets the full path to episode.
@ -69,4 +72,24 @@ public class QueuedEpisode
/// Gets or sets the total duration of this media file (in seconds). /// Gets or sets the total duration of this media file (in seconds).
/// </summary> /// </summary>
public int Duration { get; set; } public int Duration { get; set; }
/// <summary>
/// Sets a value indicating whether this media has been already analyzed.
/// </summary>
/// <param name="mode">Analysis mode.</param>
/// <param name="value">Value to set.</param>
public void SetAnalyzed(AnalysisMode mode, bool value)
{
_isAnalyzed[mode] = value;
}
/// <summary>
/// Sets a value indicating whether this media has been already analyzed.
/// </summary>
/// <param name="mode">Analysis mode.</param>
/// <returns>Value of the analyzed mode.</returns>
public bool GetAnalyzed(AnalysisMode mode)
{
return _isAnalyzed.TryGetValue(mode, out var value) && value;
}
} }

View File

@ -2,7 +2,6 @@
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Globalization;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;

View File

@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
namespace IntroSkipper.Data
{
/// <summary>
/// /// Update analyzer actions request.
/// </summary>
public class UpdateAnalyzerActionsRequest
{
/// <summary>
/// Gets or sets season ID.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets analyzer actions.
/// </summary>
public IReadOnlyDictionary<AnalysisMode, AnalyzerAction> AnalyzerActions { get; set; } = new Dictionary<AnalysisMode, AnalyzerAction>();
}
}

View File

@ -0,0 +1,51 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using IntroSkipper.Data;
namespace IntroSkipper.Db;
/// <summary>
/// All times are measured in seconds relative to the beginning of the media file.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="DbSeasonInfo"/> class.
/// </remarks>
public class DbSeasonInfo
{
/// <summary>
/// Initializes a new instance of the <see cref="DbSeasonInfo"/> class.
/// </summary>
/// <param name="seasonId">Season ID.</param>
/// <param name="mode">Analysis mode.</param>
/// <param name="action">Analyzer action.</param>
public DbSeasonInfo(Guid seasonId, AnalysisMode mode, AnalyzerAction action)
{
SeasonId = seasonId;
Type = mode;
Action = action;
}
/// <summary>
/// Initializes a new instance of the <see cref="DbSeasonInfo"/> class.
/// </summary>
public DbSeasonInfo()
{
}
/// <summary>
/// Gets the item ID.
/// </summary>
public Guid SeasonId { get; private set; }
/// <summary>
/// Gets the analysis mode.
/// </summary>
public AnalysisMode Type { get; private set; }
/// <summary>
/// Gets the analyzer action.
/// </summary>
public AnalyzerAction Action { get; private set; }
}

View File

@ -0,0 +1,56 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using IntroSkipper.Data;
namespace IntroSkipper.Db;
/// <summary>
/// All times are measured in seconds relative to the beginning of the media file.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="DbSegment"/> class.
/// </remarks>
public class DbSegment
{
/// <summary>
/// Initializes a new instance of the <see cref="DbSegment"/> class.
/// </summary>
/// <param name="segment">Segment.</param>
/// <param name="mode">Analysis mode.</param>
public DbSegment(Segment segment, AnalysisMode mode)
{
ItemId = segment.EpisodeId;
Start = segment.Start;
End = segment.End;
Type = mode;
}
/// <summary>
/// Initializes a new instance of the <see cref="DbSegment"/> class.
/// </summary>
public DbSegment()
{
}
/// <summary>
/// Gets the item ID.
/// </summary>
public Guid ItemId { get; private set; }
/// <summary>
/// Gets the start time.
/// </summary>
public double Start { get; private set; }
/// <summary>
/// Gets the end time.
/// </summary>
public double End { get; private set; }
/// <summary>
/// Gets the analysis mode.
/// </summary>
public AnalysisMode Type { get; private set; }
}

View File

@ -0,0 +1,80 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using Microsoft.EntityFrameworkCore;
namespace IntroSkipper.Db;
/// <summary>
/// Plugin database.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="IntroSkipperDbContext"/> class.
/// </remarks>
/// <param name="dbPath">The path to the SQLite database file.</param>
public class IntroSkipperDbContext(string dbPath) : DbContext
{
private readonly string _dbPath = dbPath ?? throw new ArgumentNullException(nameof(dbPath));
/// <summary>
/// Gets or sets the <see cref="DbSet{TEntity}"/> containing the segments.
/// </summary>
public DbSet<DbSegment> DbSegment { get; set; } = null!;
/// <summary>
/// Gets or sets the <see cref="DbSet{TEntity}"/> containing the season information.
/// </summary>
public DbSet<DbSeasonInfo> DbSeasonInfo { get; set; } = null!;
/// <inheritdoc/>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite($"Data Source={_dbPath}")
.EnableSensitiveDataLogging(false);
}
/// <inheritdoc/>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DbSegment>(entity =>
{
entity.ToTable("DbSegment");
entity.HasKey(s => new { s.ItemId, s.Type });
entity.Property(e => e.ItemId)
.IsRequired();
entity.Property(e => e.Type)
.IsRequired();
entity.Property(e => e.Start);
entity.Property(e => e.End);
});
modelBuilder.Entity<DbSeasonInfo>(entity =>
{
entity.ToTable("DbSeasonInfo");
entity.HasKey(s => new { s.SeasonId, s.Type });
entity.Property(e => e.SeasonId)
.IsRequired();
entity.Property(e => e.Type)
.IsRequired();
entity.Property(e => e.Action);
});
base.OnModelCreating(modelBuilder);
}
/// <summary>
/// Applies any pending migrations to the database.
/// </summary>
public void ApplyMigrations()
{
Database.Migrate();
}
}

View File

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

View File

@ -292,39 +292,31 @@ namespace IntroSkipper.Manager
VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<AnalysisMode> modes) VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<AnalysisMode> modes)
{ {
var verified = new List<QueuedEpisode>(); var verified = new List<QueuedEpisode>();
var reqModes = new HashSet<AnalysisMode>(); var reqModes = new HashSet<AnalysisMode>(modes); // Start with all modes and remove completed ones
foreach (var candidate in candidates) foreach (var candidate in candidates)
{ {
try try
{ {
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId); var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
if (!File.Exists(path)) if (!File.Exists(path))
{ {
continue; continue;
} }
verified.Add(candidate); verified.Add(candidate);
var segments = Plugin.Instance!.GetSegmentsById(candidate.EpisodeId);
foreach (var mode in modes) foreach (var mode in modes)
{ {
if (candidate.State.IsAnalyzed(mode) || candidate.State.IsBlacklisted(mode)) if (segments.TryGetValue(mode, out var segment))
{ {
continue; if (segment.Valid)
} {
candidate.SetAnalyzed(mode, true);
}
bool isAnalyzed = mode == AnalysisMode.Introduction reqModes.Remove(mode);
? Plugin.Instance!.Intros.ContainsKey(candidate.EpisodeId)
: Plugin.Instance!.Credits.ContainsKey(candidate.EpisodeId);
if (isAnalyzed)
{
candidate.State.SetAnalyzed(mode, true);
}
else
{
reqModes.Add(mode);
} }
} }
} }

View File

@ -7,10 +7,12 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Xml; using System.Xml;
using System.Xml.Serialization; using System.Xml.Serialization;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Data; using IntroSkipper.Data;
using IntroSkipper.Db;
using IntroSkipper.Helper; using IntroSkipper.Helper;
using MediaBrowser.Common; using MediaBrowser.Common;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
@ -23,6 +25,7 @@ using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Updates; using MediaBrowser.Model.Updates;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace IntroSkipper; namespace IntroSkipper;
@ -32,15 +35,13 @@ namespace IntroSkipper;
/// </summary> /// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{ {
private readonly object _serializationLock = new();
private readonly object _introsLock = new();
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly IItemRepository _itemRepository; private readonly IItemRepository _itemRepository;
private readonly IApplicationHost _applicationHost; private readonly IApplicationHost _applicationHost;
private readonly ILogger<Plugin> _logger; private readonly ILogger<Plugin> _logger;
private readonly string _introPath; private readonly string _introPath;
private readonly string _creditsPath; private readonly string _creditsPath;
private string _ignorelistPath; private readonly string _dbPath;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class. /// Initializes a new instance of the <see cref="Plugin"/> class.
@ -80,7 +81,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
FingerprintCachePath = Path.Join(introsDirectory, pluginCachePath); FingerprintCachePath = Path.Join(introsDirectory, pluginCachePath);
_introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml"); _introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml");
_creditsPath = Path.Join(applicationPaths.DataPath, pluginDirName, "credits.xml"); _creditsPath = Path.Join(applicationPaths.DataPath, pluginDirName, "credits.xml");
_ignorelistPath = Path.Join(applicationPaths.DataPath, pluginDirName, "ignorelist.xml"); _dbPath = Path.Join(applicationPaths.DataPath, pluginDirName, "introskipper.db");
// Create the base & cache directories (if needed). // Create the base & cache directories (if needed).
if (!Directory.Exists(FingerprintCachePath)) if (!Directory.Exists(FingerprintCachePath))
@ -99,22 +100,18 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
try try
{ {
XmlSerializer serializer = new XmlSerializer(typeof(PluginConfiguration)); XmlSerializer serializer = new XmlSerializer(typeof(PluginConfiguration));
using (FileStream fileStream = new FileStream(oldConfigFile, FileMode.Open)) using FileStream fileStream = new FileStream(oldConfigFile, FileMode.Open);
var settings = new XmlReaderSettings
{ {
var settings = new XmlReaderSettings DtdProcessing = DtdProcessing.Prohibit, // Disable DTD processing
{ XmlResolver = null // Disable the XmlResolver
DtdProcessing = DtdProcessing.Prohibit, // Disable DTD processing };
XmlResolver = null // Disable the XmlResolver
};
using (var reader = XmlReader.Create(fileStream, settings)) using var reader = XmlReader.Create(fileStream, settings);
{ if (serializer.Deserialize(reader) is PluginConfiguration oldConfig)
if (serializer.Deserialize(reader) is PluginConfiguration oldConfig) {
{ Instance.UpdateConfiguration(oldConfig);
Instance.UpdateConfiguration(oldConfig); File.Delete(oldConfigFile);
File.Delete(oldConfigFile);
}
}
} }
} }
catch (Exception ex) catch (Exception ex)
@ -126,23 +123,20 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
MigrateRepoUrl(serverConfiguration); MigrateRepoUrl(serverConfiguration);
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete // Initialize database, restore timestamps if available.
try try
{ {
RestoreTimestamps(); using var db = new IntroSkipperDbContext(_dbPath);
db.Database.EnsureCreated();
db.ApplyMigrations();
if (File.Exists(_introPath) || File.Exists(_creditsPath))
{
RestoreTimestampsAsync(db).GetAwaiter().GetResult();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex); _logger.LogWarning("Error initializing database: {Exception}", ex);
}
try
{
LoadIgnoreList();
}
catch (Exception ex)
{
_logger.LogWarning("Unable to load ignore list: {Exception}", ex);
} }
// Inject the skip intro button code into the web interface. // Inject the skip intro button code into the web interface.
@ -161,30 +155,15 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
} }
/// <summary> /// <summary>
/// Gets the results of fingerprinting all episodes. /// Gets the path to the database.
/// </summary> /// </summary>
public ConcurrentDictionary<Guid, Segment> Intros { get; } = new(); public string DbPath => _dbPath;
/// <summary>
/// Gets all discovered ending credits.
/// </summary>
public ConcurrentDictionary<Guid, Segment> Credits { get; } = new();
/// <summary> /// <summary>
/// Gets the most recent media item queue. /// Gets the most recent media item queue.
/// </summary> /// </summary>
public ConcurrentDictionary<Guid, List<QueuedEpisode>> QueuedMediaItems { get; } = new(); public ConcurrentDictionary<Guid, List<QueuedEpisode>> QueuedMediaItems { get; } = new();
/// <summary>
/// Gets all episode states.
/// </summary>
public ConcurrentDictionary<Guid, EpisodeState> EpisodeStates { get; } = new();
/// <summary>
/// Gets the ignore list.
/// </summary>
public ConcurrentDictionary<Guid, IgnoreListItem> IgnoreList { get; } = new();
/// <summary> /// <summary>
/// Gets or sets the total number of episodes in the queue. /// Gets or sets the total number of episodes in the queue.
/// </summary> /// </summary>
@ -216,109 +195,39 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary> /// </summary>
public static Plugin? Instance { get; private set; } public static Plugin? Instance { get; private set; }
/// <summary>
/// Save timestamps to disk.
/// </summary>
/// <param name="mode">Mode.</param>
public void SaveTimestamps(AnalysisMode mode)
{
List<Segment> introList = [];
var filePath = mode == AnalysisMode.Introduction
? _introPath
: _creditsPath;
lock (_introsLock)
{
introList.AddRange(mode == AnalysisMode.Introduction
? Instance!.Intros.Values
: Instance!.Credits.Values);
}
lock (_serializationLock)
{
try
{
XmlSerializationHelper.SerializeToXml(introList, filePath);
}
catch (Exception e)
{
_logger.LogError("SaveTimestamps {Message}", e.Message);
}
}
}
/// <summary>
/// Save IgnoreList to disk.
/// </summary>
public void SaveIgnoreList()
{
var ignorelist = Instance!.IgnoreList.Values.ToList();
lock (_serializationLock)
{
try
{
XmlSerializationHelper.SerializeToXml(ignorelist, _ignorelistPath);
}
catch (Exception e)
{
_logger.LogError("SaveIgnoreList {Message}", e.Message);
}
}
}
/// <summary>
/// Check if an item is ignored.
/// </summary>
/// <param name="id">Item id.</param>
/// <param name="mode">Mode.</param>
/// <returns>True if ignored, false otherwise.</returns>
public bool IsIgnored(Guid id, AnalysisMode mode)
{
return Instance!.IgnoreList.TryGetValue(id, out var item) && item.IsIgnored(mode);
}
/// <summary>
/// Load IgnoreList from disk.
/// </summary>
public void LoadIgnoreList()
{
if (File.Exists(_ignorelistPath))
{
var ignorelist = XmlSerializationHelper.DeserializeFromXml<IgnoreListItem>(_ignorelistPath);
foreach (var item in ignorelist)
{
Instance!.IgnoreList.TryAdd(item.SeasonId, item);
}
}
}
/// <summary> /// <summary>
/// Restore previous analysis results from disk. /// Restore previous analysis results from disk.
/// </summary> /// </summary>
public void RestoreTimestamps() /// <param name="db">IntroSkipperDbContext.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task RestoreTimestampsAsync(IntroSkipperDbContext db)
{ {
// Import intros
if (File.Exists(_introPath)) if (File.Exists(_introPath))
{ {
// Since dictionaries can't be easily serialized, analysis results are stored on disk as a list.
var introList = XmlSerializationHelper.DeserializeFromXml<Segment>(_introPath); var introList = XmlSerializationHelper.DeserializeFromXml<Segment>(_introPath);
foreach (var intro in introList) foreach (var intro in introList)
{ {
Instance!.Intros.TryAdd(intro.EpisodeId, intro); var dbSegment = new DbSegment(intro, AnalysisMode.Introduction);
db.DbSegment.Add(dbSegment);
} }
} }
// Import credits
if (File.Exists(_creditsPath)) if (File.Exists(_creditsPath))
{ {
var creditList = XmlSerializationHelper.DeserializeFromXml<Segment>(_creditsPath); var creditList = XmlSerializationHelper.DeserializeFromXml<Segment>(_creditsPath);
foreach (var credit in creditList) foreach (var credit in creditList)
{ {
Instance!.Credits.TryAdd(credit.EpisodeId, credit); var dbSegment = new DbSegment(credit, AnalysisMode.Credits);
db.DbSegment.Add(dbSegment);
} }
} }
await db.SaveChangesAsync().ConfigureAwait(false);
File.Delete(_introPath);
File.Delete(_creditsPath);
} }
/// <inheritdoc /> /// <inheritdoc />
@ -344,19 +253,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
]; ];
} }
/// <summary>
/// Gets the Intro for this item.
/// </summary>
/// <param name="id">Item id.</param>
/// <param name="mode">Mode.</param>
/// <returns>Intro.</returns>
internal static Segment GetIntroByMode(Guid id, AnalysisMode mode)
{
return mode == AnalysisMode.Introduction
? Instance!.Intros[id]
: Instance!.Credits[id];
}
internal BaseItem? GetItem(Guid id) internal BaseItem? GetItem(Guid id)
{ {
return id != Guid.Empty ? _libraryManager.GetItemById(id) : null; return id != Guid.Empty ? _libraryManager.GetItemById(id) : null;
@ -404,46 +300,126 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
return _itemRepository.GetChapters(item); return _itemRepository.GetChapters(item);
} }
/// <summary> internal async Task UpdateTimestamps(IReadOnlyDictionary<Guid, Segment> newTimestamps, AnalysisMode mode)
/// Gets the state for this item.
/// </summary>
/// <param name="id">Item ID.</param>
/// <returns>State of this item.</returns>
internal EpisodeState GetState(Guid id) => EpisodeStates.GetOrAdd(id, _ => new EpisodeState());
internal void UpdateTimestamps(IReadOnlyDictionary<Guid, Segment> newTimestamps, AnalysisMode mode)
{ {
foreach (var intro in newTimestamps) using var db = new IntroSkipperDbContext(_dbPath);
// Get all existing segments in a single query
var existingSegments = db.DbSegment
.Where(s => newTimestamps.Keys.Contains(s.ItemId) && s.Type == mode)
.ToDictionary(s => s.ItemId);
// Batch updates and inserts
var segmentsToAdd = new List<DbSegment>();
foreach (var (itemId, segment) in newTimestamps)
{ {
if (mode == AnalysisMode.Introduction) var dbSegment = new DbSegment(segment, mode);
if (existingSegments.TryGetValue(itemId, out var existing))
{ {
Instance!.Intros.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value); db.Entry(existing).CurrentValues.SetValues(dbSegment);
} }
else if (mode == AnalysisMode.Credits) else
{ {
Instance!.Credits.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value); segmentsToAdd.Add(dbSegment);
} }
} }
SaveTimestamps(mode); if (segmentsToAdd.Count > 0)
{
await db.DbSegment.AddRangeAsync(segmentsToAdd).ConfigureAwait(false);
}
await db.SaveChangesAsync().ConfigureAwait(false);
} }
internal void CleanTimestamps(HashSet<Guid> validEpisodeIds) internal async Task ClearInvalidSegments()
{ {
var allKeys = new HashSet<Guid>(Instance!.Intros.Keys); using var db = new IntroSkipperDbContext(_dbPath);
allKeys.UnionWith(Instance!.Credits.Keys); db.DbSegment.RemoveRange(db.DbSegment.Where(s => s.End == 0));
await db.SaveChangesAsync().ConfigureAwait(false);
}
foreach (var key in allKeys) internal async Task CleanTimestamps(HashSet<Guid> episodeIds)
{
using var db = new IntroSkipperDbContext(_dbPath);
db.DbSegment.RemoveRange(db.DbSegment
.Where(s => !episodeIds.Contains(s.ItemId)));
await db.SaveChangesAsync().ConfigureAwait(false);
}
internal IReadOnlyDictionary<AnalysisMode, Segment> GetSegmentsById(Guid id)
{
using var db = new IntroSkipperDbContext(_dbPath);
return db.DbSegment
.Where(s => s.ItemId == id)
.ToDictionary(
s => s.Type,
s => new Segment
{
EpisodeId = s.ItemId,
Start = s.Start,
End = s.End
});
}
internal Segment GetSegmentByMode(Guid id, AnalysisMode mode)
{
using var db = new IntroSkipperDbContext(_dbPath);
return db.DbSegment
.Where(s => s.ItemId == id && s.Type == mode)
.Select(s => new Segment
{
EpisodeId = s.ItemId,
Start = s.Start,
End = s.End
}).FirstOrDefault() ?? new Segment(id);
}
internal async Task UpdateAnalyzerActionAsync(Guid id, IReadOnlyDictionary<AnalysisMode, AnalyzerAction> analyzerActions)
{
using var db = new IntroSkipperDbContext(_dbPath);
var existingEntries = await db.DbSeasonInfo
.Where(s => s.SeasonId == id)
.ToDictionaryAsync(s => s.Type)
.ConfigureAwait(false);
foreach (var (mode, action) in analyzerActions)
{ {
if (!validEpisodeIds.Contains(key)) var dbSeasonInfo = new DbSeasonInfo(id, mode, action);
if (existingEntries.TryGetValue(mode, out var existing))
{ {
Instance!.Intros.TryRemove(key, out _); db.Entry(existing).CurrentValues.SetValues(dbSeasonInfo);
Instance!.Credits.TryRemove(key, out _); }
else
{
db.DbSeasonInfo.Add(dbSeasonInfo);
} }
} }
SaveTimestamps(AnalysisMode.Introduction); await db.SaveChangesAsync().ConfigureAwait(false);
SaveTimestamps(AnalysisMode.Credits); }
internal IReadOnlyDictionary<AnalysisMode, AnalyzerAction> GetAnalyzerAction(Guid id)
{
using var db = new IntroSkipperDbContext(_dbPath);
return db.DbSeasonInfo.Where(s => s.SeasonId == id).ToDictionary(s => s.Type, s => s.Action);
}
internal AnalyzerAction GetAnalyzerAction(Guid id, AnalysisMode mode)
{
using var db = new IntroSkipperDbContext(_dbPath);
return db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode)?.Action ?? AnalyzerAction.Default;
}
internal async Task CleanSeasonInfoAsync()
{
using var db = new IntroSkipperDbContext(_dbPath);
var obsoleteSeasons = await db.DbSeasonInfo
.Where(s => !Instance!.QueuedMediaItems.Keys.Contains(s.SeasonId))
.ToListAsync().ConfigureAwait(false);
db.DbSeasonInfo.RemoveRange(obsoleteSeasons);
await db.SaveChangesAsync().ConfigureAwait(false);
} }
private void MigrateRepoUrl(IServerConfigurationManager serverConfiguration) private void MigrateRepoUrl(IServerConfigurationManager serverConfiguration)

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IntroSkipper.Data;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
@ -26,30 +27,37 @@ namespace IntroSkipper.Providers
/// <inheritdoc/> /// <inheritdoc/>
public Task<IReadOnlyList<MediaSegmentDto>> GetMediaSegments(MediaSegmentGenerationRequest request, CancellationToken cancellationToken) public Task<IReadOnlyList<MediaSegmentDto>> GetMediaSegments(MediaSegmentGenerationRequest request, CancellationToken cancellationToken)
{ {
var segments = new List<MediaSegmentDto>(); ArgumentNullException.ThrowIfNull(request);
var remainingTicks = (Plugin.Instance?.Configuration.RemainingSecondsOfIntro ?? 2) * TimeSpan.TicksPerSecond; ArgumentNullException.ThrowIfNull(Plugin.Instance);
if (Plugin.Instance!.Intros.TryGetValue(request.ItemId, out var introValue)) var segments = new List<MediaSegmentDto>();
var remainingTicks = Plugin.Instance.Configuration.RemainingSecondsOfIntro * TimeSpan.TicksPerSecond;
var itemSegments = Plugin.Instance.GetSegmentsById(request.ItemId);
// Add intro segment if found
if (itemSegments.TryGetValue(AnalysisMode.Introduction, out var introSegment) && introSegment.Valid)
{ {
segments.Add(new MediaSegmentDto segments.Add(new MediaSegmentDto
{ {
StartTicks = (long)(introValue.Start * TimeSpan.TicksPerSecond), StartTicks = (long)(introSegment.Start * TimeSpan.TicksPerSecond),
EndTicks = (long)(introValue.End * TimeSpan.TicksPerSecond) - remainingTicks, EndTicks = (long)(introSegment.End * TimeSpan.TicksPerSecond) - remainingTicks,
ItemId = request.ItemId, ItemId = request.ItemId,
Type = MediaSegmentType.Intro Type = MediaSegmentType.Intro
}); });
} }
if (Plugin.Instance!.Credits.TryGetValue(request.ItemId, out var creditValue)) // Add outro/credits segment if found
if (itemSegments.TryGetValue(AnalysisMode.Introduction, out var creditSegment) && creditSegment.Valid)
{ {
var creditEndTicks = (long)(creditValue.End * TimeSpan.TicksPerSecond); var creditEndTicks = (long)(creditSegment.End * TimeSpan.TicksPerSecond);
var runTimeTicks = Plugin.Instance.GetItem(request.ItemId)?.RunTimeTicks ?? long.MaxValue; var runTimeTicks = Plugin.Instance.GetItem(request.ItemId)?.RunTimeTicks ?? long.MaxValue;
segments.Add(new MediaSegmentDto segments.Add(new MediaSegmentDto
{ {
StartTicks = (long)(creditValue.Start * TimeSpan.TicksPerSecond), StartTicks = (long)(creditSegment.Start * TimeSpan.TicksPerSecond),
EndTicks = runTimeTicks > creditEndTicks + TimeSpan.TicksPerSecond EndTicks = runTimeTicks > creditEndTicks + TimeSpan.TicksPerSecond
? creditEndTicks - remainingTicks ? creditEndTicks - remainingTicks
: runTimeTicks, : runTimeTicks,
ItemId = request.ItemId, ItemId = request.ItemId,
Type = MediaSegmentType.Outro Type = MediaSegmentType.Outro
}); });

View File

@ -102,7 +102,7 @@ public class BaseItemAnalyzerTask
// of the current media items were deleted from Jellyfin since the task was started. // of the current media items were deleted from Jellyfin since the task was started.
var (episodes, requiredModes) = queueManager.VerifyQueue( var (episodes, requiredModes) = queueManager.VerifyQueue(
season.Value, season.Value,
_analysisModes.Where(m => !Plugin.Instance!.IsIgnored(season.Key, m)).ToList()); _analysisModes);
if (episodes.Count == 0) if (episodes.Count == 0)
{ {
@ -132,7 +132,8 @@ public class BaseItemAnalyzerTask
foreach (AnalysisMode mode in requiredModes) foreach (AnalysisMode mode in requiredModes)
{ {
var analyzed = AnalyzeItems(episodes, mode, ct); var action = Plugin.Instance!.GetAnalyzerAction(season.Key, mode);
var analyzed = await AnalyzeItems(episodes, mode, action, ct).ConfigureAwait(false);
Interlocked.Add(ref totalProcessed, analyzed); Interlocked.Add(ref totalProcessed, analyzed);
updateManagers = analyzed > 0 || updateManagers; updateManagers = analyzed > 0 || updateManagers;
@ -177,14 +178,16 @@ public class BaseItemAnalyzerTask
/// </summary> /// </summary>
/// <param name="items">Media items to analyze.</param> /// <param name="items">Media items to analyze.</param>
/// <param name="mode">Analysis mode.</param> /// <param name="mode">Analysis mode.</param>
/// <param name="action">Analyzer action.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of items that were successfully analyzed.</returns> /// <returns>Number of items that were successfully analyzed.</returns>
private int AnalyzeItems( private async Task<int> AnalyzeItems(
IReadOnlyList<QueuedEpisode> items, IReadOnlyList<QueuedEpisode> items,
AnalysisMode mode, AnalysisMode mode,
AnalyzerAction action,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var totalItems = items.Count(e => !e.State.IsAnalyzed(mode)); var totalItems = items.Count(e => !e.GetAnalyzed(mode));
// Only analyze specials (season 0) if the user has opted in. // Only analyze specials (season 0) if the user has opted in.
var first = items[0]; var first = items[0];
@ -193,12 +196,6 @@ public class BaseItemAnalyzerTask
return 0; return 0;
} }
// Remove from Blacklist
foreach (var item in items.Where(e => e.State.IsBlacklisted(mode)))
{
item.State.SetBlacklisted(mode, false);
}
_logger.LogInformation( _logger.LogInformation(
"[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}", "[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}",
mode, mode,
@ -206,22 +203,24 @@ public class BaseItemAnalyzerTask
first.SeriesName, first.SeriesName,
first.SeasonNumber); first.SeasonNumber);
var analyzers = new Collection<IMediaFileAnalyzer> var analyzers = new Collection<IMediaFileAnalyzer>();
{
new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>())
};
if (first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint && !first.IsMovie) if (action == AnalyzerAction.Chapter || action == AnalyzerAction.Default)
{
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
}
if (first.IsAnime && !first.IsMovie && (action == AnalyzerAction.Chromaprint || action == AnalyzerAction.Default))
{ {
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>())); analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
} }
if (mode == AnalysisMode.Credits) if (mode == AnalysisMode.Credits && (action == AnalyzerAction.BlackFrame || action == AnalyzerAction.Default))
{ {
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>())); analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
} }
if (!first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint && !first.IsMovie) if (!first.IsAnime && !first.IsMovie && (action == AnalyzerAction.Chromaprint || action == AnalyzerAction.Default))
{ {
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>())); analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
} }
@ -230,16 +229,14 @@ public class BaseItemAnalyzerTask
// analyzed items from the queue. // analyzed items from the queue.
foreach (var analyzer in analyzers) foreach (var analyzer in analyzers)
{ {
items = analyzer.AnalyzeMediaFiles(items, mode, cancellationToken); items = await analyzer.AnalyzeMediaFiles(items, mode, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
} }
// Add items without intros/credits to blacklist. // Add items without intros/credits to blacklist.
foreach (var item in items.Where(e => !e.State.IsAnalyzed(mode))) var blacklisted = items.Where(e => !e.GetAnalyzed(mode)).ToList();
{ await Plugin.Instance!.UpdateTimestamps(blacklisted.ToDictionary(e => e.EpisodeId, e => new Segment(e.EpisodeId)), mode).ConfigureAwait(false);
item.State.SetBlacklisted(mode, true); totalItems -= blacklisted.Count;
totalItems -= 1;
}
return totalItems; return totalItems;
} }

View File

@ -69,7 +69,7 @@ public class CleanCacheTask : IScheduledTask
/// <param name="progress">Task progress.</param> /// <param name="progress">Task progress.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task.</returns> /// <returns>Task.</returns>
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{ {
if (_libraryManager is null) if (_libraryManager is null)
{ {
@ -82,24 +82,19 @@ public class CleanCacheTask : IScheduledTask
// Retrieve media items and get valid episode IDs // Retrieve media items and get valid episode IDs
var queue = queueManager.GetMediaItems(); var queue = queueManager.GetMediaItems();
var validEpisodeIds = new HashSet<Guid>(queue.Values.SelectMany(episodes => episodes.Select(e => e.EpisodeId))); var validEpisodeIds = queue.Values
.SelectMany(episodes => episodes.Select(e => e.EpisodeId))
.ToHashSet();
Plugin.Instance!.CleanTimestamps(validEpisodeIds); await Plugin.Instance!.ClearInvalidSegments().ConfigureAwait(false);
await Plugin.Instance!.CleanTimestamps(validEpisodeIds).ConfigureAwait(false);
// Identify invalid episode IDs // Identify invalid episode IDs
var invalidEpisodeIds = Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath) var invalidEpisodeIds = Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath)
.Select(filePath => .Select(filePath => Path.GetFileNameWithoutExtension(filePath).Split('-')[0])
{ .Where(episodeIdStr => Guid.TryParse(episodeIdStr, out var episodeId) && !validEpisodeIds.Contains(episodeId))
var fileName = Path.GetFileNameWithoutExtension(filePath); .Select(Guid.Parse)
var episodeIdStr = fileName.Split('-')[0];
if (Guid.TryParse(episodeIdStr, out Guid episodeId))
{
return validEpisodeIds.Contains(episodeId) ? (Guid?)null : episodeId;
}
return null;
})
.OfType<Guid>()
.ToHashSet(); .ToHashSet();
// Delete cache files for invalid episode IDs // Delete cache files for invalid episode IDs
@ -109,31 +104,10 @@ public class CleanCacheTask : IScheduledTask
FFmpegWrapper.DeleteEpisodeCache(episodeId); FFmpegWrapper.DeleteEpisodeCache(episodeId);
} }
// Clean up ignore list by removing items that are no longer exist.. // Clean up Season information by removing items that are no longer exist.
var removedItems = false; await Plugin.Instance!.CleanSeasonInfoAsync().ConfigureAwait(false);
foreach (var ignoredItem in Plugin.Instance.IgnoreList.Values.ToList())
{
if (!queue.ContainsKey(ignoredItem.SeasonId))
{
removedItems = true;
Plugin.Instance.IgnoreList.TryRemove(ignoredItem.SeasonId, out _);
}
}
// Save ignore list if at least one item was removed. progress.Report(100);
if (removedItems)
{
try
{
Plugin.Instance!.SaveIgnoreList();
}
catch (Exception e)
{
_logger.LogError("Failed to save ignore list: {Error}", e.Message);
}
}
return Task.CompletedTask;
} }
/// <summary> /// <summary>

View File

@ -8,6 +8,8 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Timers; using System.Timers;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Data;
using IntroSkipper.Db;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Session;
@ -125,7 +127,8 @@ namespace IntroSkipper.Services
} }
// Assert that an intro was detected for this item. // Assert that an intro was detected for this item.
if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid) var intro = Plugin.Instance!.GetSegmentByMode(itemId, AnalysisMode.Introduction);
if (!intro.Valid)
{ {
continue; continue;
} }

View File

@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Timers; using System.Timers;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Data;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Session;
@ -125,7 +126,8 @@ namespace IntroSkipper.Services
} }
// Assert that credits were detected for this item. // Assert that credits were detected for this item.
if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var credit) || !credit.Valid) var credit = Plugin.Instance!.GetSegmentByMode(itemId, AnalysisMode.Credits);
if (!credit.Valid)
{ {
continue; continue;
} }

View File

@ -140,7 +140,11 @@ namespace IntroSkipper.Services
} }
} }
private void OnSettingsChanged(object? sender, BasePluginConfiguration e) => _config = (PluginConfiguration)e; private void OnSettingsChanged(object? sender, BasePluginConfiguration e)
{
_config = (PluginConfiguration)e;
_ = Plugin.Instance!.ClearInvalidSegments();
}
/// <summary> /// <summary>
/// Start timer to debounce analyzing. /// Start timer to debounce analyzing.