Compare commits

..

57 Commits
10.10 ... 10.9

Author SHA1 Message Date
TwistedUmbrellaX
350c7684e3
Titles only for old versions 2024-11-26 13:14:39 -05:00
TwistedUmbrellaX
6f504132ac
Change a wiki heading 2024-11-26 13:10:18 -05:00
github-actions[bot]
d3fb5ff8d7 release v1.10.9.2 2024-11-05 14:36:23 +00:00
Kilian von Pflugk
df28ef8be7 ci: don't build on PRs (#370) 2024-11-05 14:29:21 +01:00
TwistedUmbrellaX
25d6700c1a List the right ffmpeg version in log 2024-11-05 06:18:32 -05:00
TwistedUmbrellaX
71c2c69605 Link to the wiki sections directly 2024-11-03 05:12:06 -05:00
TwistedUmbrellaX
390ff41ac3 Move UI items into UI section 2024-10-31 12:05:23 -04:00
Kilian von Pflugk
d9973ed90a check that old config is not null 2024-10-30 20:37:56 +01:00
Kilian von Pflugk
36825a898d fix namespace 2024-10-30 20:37:49 +01:00
TwistedUmbrellaX
74fd8f3f75 Update configPage.html 2024-10-30 09:55:28 -04:00
TwistedUmbrellaX
e1420027c0 Update PluginConfiguration.cs 2024-10-30 09:55:28 -04:00
Kilian von Pflugk
89f996100c Revert "Temporary workaround"
This reverts commit 61739a1afd6f2db4a87c12c44e69a4c6a039b2e5.
2024-10-29 21:23:27 +01:00
TwistedUmbrellaX
61739a1afd Temporary workaround 2024-10-29 14:52:44 -04:00
TwistedUmbrellaX
3e92d0f6f5 Rename a duplicate label 2024-10-29 10:19:19 -04:00
TwistedUmbrellaX
01a6d855f9 Stop introducing this typo
It keeps popping up randomly
2024-10-29 07:42:07 -04:00
TwistedUmbrellaX
6bb92eda01 fix a typo 2024-10-28 20:50:39 -04:00
TwistedUmbrellaX
f9611eae78 Some additional formatting 2024-10-28 20:48:54 -04:00
TwistedUmbrellaX
8f46af69e4 More standardized formatting 2024-10-28 20:34:31 -04:00
TwistedUmbrellaX
b4fdf14f26 Reorganize settings by relation 2024-10-28 20:33:12 -04:00
TwistedUmbrellaX
9893aac067 Fix a typo in the config page editor 2024-10-27 21:41:29 -04:00
TwistedUmbrellaX
e68eaf8c0e Update ignore for alternate IDE 2024-10-27 21:15:25 -04:00
Kilian von Pflugk
f32408109d allow users to override the URL (#360) 2024-10-27 22:43:15 +01:00
TwistedUmbrellaX
97840f2a7d Swap editor and support dropdown 2024-10-27 12:51:11 -04:00
TwistedUmbrellaX
9ff8b19ab5 Warn without disabling toggle 2024-10-27 04:44:54 -04:00
TwistedUmbrellaX
2653c0a314
Restart versioning for manifest 2024-10-26 14:13:06 -04:00
github-actions[bot]
80bbc0fa00 release v1.10.9.1 2024-10-26 18:09:56 +00:00
TwistedUmbrellaX
e9582d431c
Realign versioning with jellyfin 2024-10-26 14:03:11 -04:00
TwistedUmbrellaX
40474b2d3b And Chrome is somehow worse 2024-10-25 22:12:43 -04:00
TwistedUmbrellaX
073245a890 JMP breaks inline strong tags 2024-10-25 20:13:03 -04:00
Kilian von Pflugk
bd2b6d4bca ci: remove cloudflare deploy 2024-10-25 21:37:54 +02:00
TwistedUmbrellaX
45279e0a85 Fix identifier and name 2024-10-25 14:33:26 -04:00
TwistedUmbrellaX
7de97ae7da Implement SPDX GLPv3.0 LICENSE 2024-10-25 13:42:34 -04:00
TwistedUmbrellaX
a9baea5c16 Update LICENSE for Intro-Skipper 2024-10-25 12:23:15 -04:00
TwistedUmbrellaX
06f69e6602 Update README.md 2024-10-24 22:29:52 -04:00
Kilian von Pflugk
45d9e4b632 switch to our new domain 2024-10-24 22:48:12 +02:00
rlauu
4d7dbf7f0f check if valid 2024-10-23 10:33:28 -04:00
rlauu
fe57d4defa Skip Applying RemainingSecondsOfIntro for Segments at the End of the Video 2024-10-23 15:19:18 +02:00
rlauuzo
3445bbaee4 typo 2024-10-23 13:43:31 +02:00
TwistedUmbrellaX
38bc136088 Formatting for error messages 2024-10-22 20:46:33 -04:00
TwistedUmbrellaX
50ac67113a Make the ignore button label specific 2024-10-22 20:46:33 -04:00
rlauuzo
627ae05def analyze movies (#348)
* scan movies

* Update ConfusedPolarBear.Plugin.IntroSkipper.csproj

* fix

* Update SegmentProvider.cs

* fix

* update

* add movies to endpoints

* Update

* Update QueueManager.cs

* revert

* Update configPage.html

Battery died. I’ll be back

* “Borrow” show config to hide seasons

* Add IsMovie to ShowInfos

* remove unused usings

* Add option to enable/disble movies

* Use the left episode as movie editor

* Timestamp erasure for movies

* Add max credits duration for movies

* Formatting and button style cleanup

* remove fingerprint timings for movies

* remove x2 from MaximumCreditsDuration in blackframe analyzer

* Update SegmentProvider.cs

* Update SegmentProvider.cs

* Update SegmentProvider.cs

* Update SegmentProvider.cs

* Update BaseItemAnalyzerTask.cs

---------

Co-Authored-By: rlauu <46294892+rlauu@users.noreply.github.com>
Co-Authored-By: TwistedUmbrellaX <1173913+AbandonedCart@users.noreply.github.com>
2024-10-22 20:46:33 -04:00
Kilian von Pflugk
7198dfdfca add recommend vs code settings 2024-10-21 21:01:22 +02:00
Kilian von Pflugk
acb902824d migrate old plugin config 2024-10-20 14:02:40 +02:00
Kilian von Pflugk
fef3b4d178 rename ConfusedPolarBear.Plugin.IntroSkipper -> IntroSkipper 2024-10-20 14:02:32 +02:00
Kilian von Pflugk
8627203748 ci: make github actions reusable 2024-10-18 12:54:28 +02:00
Kilian von Pflugk
8d0f17e18b
remove old release 2024-10-17 18:18:12 +02:00
Kilian von Pflugk
6387d0e6a2 ci: use correct branch for tag creating 2024-10-17 17:55:50 +02:00
github-actions[bot]
bdc7c9914c release v1.0.0.7 2024-10-17 15:28:32 +00:00
TwistedUmbrellaX
e1ad284fab Formatting and button style cleanup 2024-10-16 22:04:09 -04:00
Kilian von Pflugk
e59bc24965 ci: send the new manifest to cloudflare KV 2024-10-13 21:38:14 +02:00
Kilian von Pflugk
12d82da4fa
ci: add jellyfin version 2024-10-13 19:21:25 +00:00
github-actions[bot]
7327f3f46e release v1.0.0.6 2024-10-12 14:25:03 +00:00
Kilian von Pflugk
84801c8634
migrate own repo url (#345) 2024-10-12 16:13:37 +02:00
Kilian von Pflugk
2b87b122c2
manifest.json 2024-10-12 14:39:17 +02:00
Kilian von Pflugk
4cdef4f228
switch to new manifest url 2024-10-12 14:18:01 +02:00
rlauu
7264b7b8f0 Remove the EDL option for the skip button since it's not working 2024-10-12 12:30:25 +02:00
Kilian von Pflugk
d1bdf764d1 10.9 2024-10-11 19:00:23 +02:00
61 changed files with 3366 additions and 2950 deletions

View File

@ -13,7 +13,7 @@ body:
Many servers have permission issues that can be resolved with a few extra steps. Many servers have permission issues that can be resolved with a few extra steps.
If your skip button is not shown, please see [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible) before reporting. If your skip button is not shown, please see [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible) before reporting.
options: options:
- label: I use Jellyfin 10.10.3 (or newer) and my permissions are correct - label: I use Jellyfin 10.9.11 (or newer) and my permissions are correct
required: true required: true
- type: textarea - type: textarea
attributes: attributes:

View File

@ -55,13 +55,13 @@ jobs:
run: dotnet restore run: dotnet restore
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 uses: github/codeql-action/init@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended queries: +security-extended
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 uses: github/codeql-action/autobuild@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 uses: github/codeql-action/analyze@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11

View File

@ -8,7 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

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

View File

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

View File

@ -0,0 +1,49 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using IntroSkipper.Data;
using Xunit;
namespace IntroSkipper.Tests;
public class TestEdl
{
// Test data is from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL
[Theory]
[InlineData(5.3, 7.1, EdlAction.Cut, "5.3 7.1 0")]
[InlineData(15, 16.7, EdlAction.Mute, "15 16.7 1")]
[InlineData(420, 822, EdlAction.CommercialBreak, "420 822 3")]
[InlineData(1, 255.3, EdlAction.SceneMarker, "1 255.3 2")]
[InlineData(1.123456789, 5.654647987, EdlAction.CommercialBreak, "1.12 5.65 3")]
public void TestEdlSerialization(double start, double end, EdlAction action, string expected)
{
var intro = MakeIntro(start, end);
var actual = intro.ToEdl(action);
Assert.Equal(expected, actual);
}
[Fact]
public void TestEdlInvalidSerialization()
{
Assert.Throws<ArgumentException>(() =>
{
var intro = MakeIntro(0, 5);
intro.ToEdl(EdlAction.None);
});
}
[Theory]
[InlineData("Death Note - S01E12 - Love.mkv", "Death Note - S01E12 - Love.edl")]
[InlineData("/full/path/to/file.rm", "/full/path/to/file.edl")]
public void TestEdlPath(string mediaPath, string edlPath)
{
Assert.Equal(edlPath, EdlManager.GetEdlPath(mediaPath));
}
private static Segment MakeIntro(double start, double end)
{
return new Segment(Guid.Empty, new TimeRange(start, end));
}
}

View File

@ -39,6 +39,7 @@ Selenium is used to verify that the plugin's web interface works as expected. It
* Changing settings (will be added in the future) * Changing settings (will be added in the future)
* Maximum degree of parallelism * Maximum degree of parallelism
* Selecting libraries for analysis * Selecting libraries for analysis
* EDL settings
* Introduction requirements * Introduction requirements
* Auto skip * Auto skip
* Show/hide skip prompt * Show/hide skip prompt

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@
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;
@ -20,7 +19,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>
Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles( public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue, IReadOnlyList<QueuedEpisode> analysisQueue,
AnalysisMode mode, AnalysisMode mode,
CancellationToken cancellationToken); CancellationToken cancellationToken);

View File

@ -0,0 +1,35 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System.Collections.Generic;
using System.Threading;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.Analyzers;
/// <summary>
/// Chapter name analyzer.
/// </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,
AnalysisMode mode,
CancellationToken cancellationToken)
{
return analysisQueue;
}
}

View File

@ -1,7 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org> // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
@ -40,12 +39,17 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary> /// <summary>
/// Gets or sets the list of client to auto skip for. /// Gets or sets the list of client to auto skip for.
/// </summary> /// </summary>
public string ClientList { get; set; } = string.Empty; public string ClientList { get; set; } = "Kodi";
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to automatically scan newly added items. /// Gets or sets a value indicating whether to scan for intros during a scheduled task.
/// </summary> /// </summary>
public bool AutoDetectIntros { get; set; } = true; public bool AutoDetectIntros { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to scan for credits during a scheduled task.
/// </summary>
public bool AutoDetectCredits { get; set; }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to analyze season 0. /// Gets or sets a value indicating whether to analyze season 0.
@ -62,42 +66,22 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public bool WithChromaprint { get; set; } = true; public bool WithChromaprint { get; set; } = true;
// ===== Media Segment handling ===== // ===== EDL handling =====
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to update Media Segments. /// Gets or sets a value indicating the action to write to created EDL files.
/// </summary> /// </summary>
public bool UpdateMediaSegments { get; set; } = true; public EdlAction EdlAction { get; set; } = EdlAction.None;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to regenerate all Media Segments during the next scan. /// Gets or sets a value indicating whether to regenerate all EDL files during the next scan.
/// By default, Media Segments are only written for a season if the season had at least one newly analyzed episode. /// By default, EDL files are only written for a season if the season had at least one newly analyzed episode.
/// If this is set, all Media Segments will be regenerated and overwrite any existing Media Segemnts. /// If this is set, all EDL files will be regenerated and overwrite any existing EDL file.
/// </summary> /// </summary>
public bool RebuildMediaSegments { get; set; } = true; public bool RegenerateEdlFiles { get; set; }
// ===== Custom analysis settings ===== // ===== Custom analysis settings =====
/// <summary>
/// Gets or sets a value indicating whether Introductions should be analyzed.
/// </summary>
public bool ScanIntroduction { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether Credits should be analyzed.
/// </summary>
public bool ScanCredits { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether Recaps should be analyzed.
/// </summary>
public bool ScanRecap { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether Previews should be analyzed.
/// </summary>
public bool ScanPreview { get; set; } = true;
/// <summary> /// <summary>
/// Gets or sets the percentage of each episode's audio track to analyze. /// Gets or sets the percentage of each episode's audio track to analyze.
/// </summary> /// </summary>
@ -142,68 +126,36 @@ public class PluginConfiguration : BasePluginConfiguration
/// Gets or sets the regular expression used to detect introduction chapters. /// Gets or sets the regular expression used to detect introduction chapters.
/// </summary> /// </summary>
public string ChapterAnalyzerIntroductionPattern { get; set; } = public string ChapterAnalyzerIntroductionPattern { get; set; } =
@"(^|\s)(Intro|Introduction|OP|Opening)(?!\sEnd)(\s|$)"; @"(^|\s)(Intro|Introduction|OP|Opening)(\s|$)";
/// <summary> /// <summary>
/// Gets or sets the regular expression used to detect ending credit chapters. /// Gets or sets the regular expression used to detect ending credit chapters.
/// </summary> /// </summary>
public string ChapterAnalyzerEndCreditsPattern { get; set; } = public string ChapterAnalyzerEndCreditsPattern { get; set; } =
@"(^|\s)(Credits?|ED|Ending|Outro)(?!\sEnd)(\s|$)"; @"(^|\s)(Credits?|ED|Ending|End|Outro)(\s|$)";
/// <summary>
/// Gets or sets the regular expression used to detect Preview chapters.
/// </summary>
public string ChapterAnalyzerPreviewPattern { get; set; } =
@"(^|\s)(Preview|PV|Sneak\s?Peek|Coming\s?(Up|Soon)|Next\s+(time|on|episode)|Extra|Teaser|Trailer)(?!\sEnd)(\s|:|$)";
/// <summary>
/// Gets or sets the regular expression used to detect Recap chapters.
/// </summary>
public string ChapterAnalyzerRecapPattern { get; set; } =
@"(^|\s)(Re?cap|Sum{1,2}ary|Prev(ious(ly)?)?|(Last|Earlier)(\s\w+)?|Catch[ -]up)(?!\sEnd)(\s|:|$)";
// ===== Playback settings ===== // ===== Playback settings =====
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to show the skip intro button. /// Gets or sets a value indicating whether to show the skip intro button.
/// </summary> /// </summary>
public bool SkipButtonEnabled { get; set; } public bool SkipButtonVisible { get; set; } = true;
/// <summary> /// <summary>
/// Gets a value indicating whether to show the skip intro warning. /// Gets a value indicating whether to show the skip intro warning.
/// </summary> /// </summary>
public bool SkipButtonWarning { get => WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton); } public bool SkipButtonWarning { get => WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton); }
/// <summary>
/// Gets or sets a value indicating whether plugin options are presented to the user.
/// </summary>
public bool PluginSkip { get; set; }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether introductions should be automatically skipped. /// Gets or sets a value indicating whether introductions should be automatically skipped.
/// </summary> /// </summary>
public bool AutoSkip { get; set; } public bool AutoSkip { get; set; }
/// <summary>
/// Gets or sets the list of segment types to auto skip.
/// </summary>
public string TypeList { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether credits should be automatically skipped. /// Gets or sets a value indicating whether credits should be automatically skipped.
/// </summary> /// </summary>
public bool AutoSkipCredits { get; set; } public bool AutoSkipCredits { get; set; }
/// <summary>
/// Gets or sets a value indicating whether recap should be automatically skipped.
/// </summary>
public bool AutoSkipRecap { get; set; }
/// <summary>
/// Gets or sets a value indicating whether preview should be automatically skipped.
/// </summary>
public bool AutoSkipPreview { get; set; }
/// <summary> /// <summary>
/// Gets or sets the seconds before the intro starts to show the skip prompt at. /// Gets or sets the seconds before the intro starts to show the skip prompt at.
/// </summary> /// </summary>
@ -234,6 +186,11 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public int SecondsOfIntroStartToPlay { get; set; } public int SecondsOfIntroStartToPlay { get; set; }
/// <summary>
/// Gets or sets the amount of credit at start to play (in seconds).
/// </summary>
public int SecondsOfCreditsStartToPlay { get; set; }
// ===== Internal algorithm settings ===== // ===== Internal algorithm settings =====
/// <summary> /// <summary>
@ -278,7 +235,12 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary> /// <summary>
/// Gets or sets the notification text sent after automatically skipping an introduction. /// Gets or sets the notification text sent after automatically skipping an introduction.
/// </summary> /// </summary>
public string AutoSkipNotificationText { get; set; } = "Segment skipped"; public string AutoSkipNotificationText { get; set; } = "Intro skipped";
/// <summary>
/// Gets or sets the notification text sent after automatically skipping credits.
/// </summary>
public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
/// <summary> /// <summary>
/// Gets or sets the max degree of parallelism used when analyzing episodes. /// Gets or sets the max degree of parallelism used when analyzing episodes.

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
const introSkipper = { const introSkipper = {
originalFetch: window.fetch.bind(window), originalFetch: window.fetch.bind(window),
originalXHROpen: XMLHttpRequest.prototype.open,
d: (msg) => console.debug("[intro skipper] ", msg), d: (msg) => console.debug("[intro skipper] ", msg),
setup() { setup() {
const self = this; const self = this;
@ -9,9 +8,6 @@ const introSkipper = {
this.currentOption = this.currentOption =
localStorage.getItem("introskipperOption") || "Show Button"; localStorage.getItem("introskipperOption") || "Show Button";
window.fetch = this.fetchWrapper.bind(this); window.fetch = this.fetchWrapper.bind(this);
XMLHttpRequest.prototype.open = function (...args) {
self.xhrOpenWrapper(this, ...args);
};
document.addEventListener("viewshow", this.viewShow.bind(this)); document.addEventListener("viewshow", this.viewShow.bind(this));
this.videoPositionChanged = this.videoPositionChanged.bind(this); this.videoPositionChanged = this.videoPositionChanged.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this); this.handleEscapeKey = this.handleEscapeKey.bind(this);
@ -43,15 +39,14 @@ const introSkipper = {
fetchWrapper(resource, options) { fetchWrapper(resource, options) {
const response = this.originalFetch(resource, options); const response = this.originalFetch(resource, options);
const url = new URL(resource); const url = new URL(resource);
if (this.injectMetadata && url.pathname.includes("/MetadataEditor")) { if (url.pathname.includes("/PlaybackInfo")) {
this.processPlaybackInfo(url.pathname);
}
else if (this.injectMetadata && url.pathname.includes("/MetadataEditor")) {
this.processMetadata(url.pathname); this.processMetadata(url.pathname);
} }
return response; return response;
}, },
xhrOpenWrapper(xhr, method, url, ...rest) {
url.includes("/PlaybackInfo") && this.processPlaybackInfo(url);
return this.originalXHROpen.apply(xhr, [method, url, ...rest]);
},
async processPlaybackInfo(url) { async processPlaybackInfo(url) {
const id = this.extractId(url); const id = this.extractId(url);
if (id) { if (id) {
@ -233,8 +228,7 @@ const introSkipper = {
position > segment.IntroStart && position > segment.IntroStart &&
position < segment.IntroEnd - 3) position < segment.IntroEnd - 3)
) { ) {
segment["SegmentType"] = key; return { ...segment, SegmentType: key };
return segment;
} }
} }
return { SegmentType: "None" }; return { SegmentType: "None" };

View File

@ -3,20 +3,14 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net.Mime; using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Data; using IntroSkipper.Data;
using IntroSkipper.Db;
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;
@ -26,9 +20,14 @@ namespace IntroSkipper.Controllers;
[Authorize] [Authorize]
[ApiController] [ApiController]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateManager) : ControllerBase public class SkipIntroController : ControllerBase
{ {
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager; /// <summary>
/// Initializes a new instance of the <see cref="SkipIntroController"/> class.
/// </summary>
public SkipIntroController()
{
}
/// <summary> /// <summary>
/// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format. /// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format.
@ -44,8 +43,9 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
[FromRoute] Guid id, [FromRoute] Guid id,
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction) [FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
{ {
var intros = GetIntros(id); var intro = GetIntro(id, mode);
if (!intros.TryGetValue(mode, out var intro))
if (intro is null || !intro.Valid)
{ {
return NotFound(); return NotFound();
} }
@ -58,52 +58,34 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
/// </summary> /// </summary>
/// <param name="id">Episode ID to update timestamps for.</param> /// <param name="id">Episode ID to update timestamps for.</param>
/// <param name="timestamps">New timestamps Introduction/Credits start and end times.</param> /// <param name="timestamps">New timestamps Introduction/Credits start and end times.</param>
/// <param name="cancellationToken">Cancellation Token.</param>
/// <response code="204">New timestamps saved.</response> /// <response code="204">New timestamps saved.</response>
/// <response code="404">Given ID is not an Episode.</response> /// <response code="404">Given ID is not an Episode.</response>
/// <returns>No content.</returns> /// <returns>No content.</returns>
[Authorize(Policy = Policies.RequiresElevation)] [Authorize(Policy = Policies.RequiresElevation)]
[HttpPost("Episode/{Id}/Timestamps")] [HttpPost("Episode/{Id}/Timestamps")]
public async Task<ActionResult> UpdateTimestampsAsync([FromRoute] Guid id, [FromBody] TimeStamps timestamps, CancellationToken cancellationToken = default) public ActionResult UpdateTimestamps([FromRoute] Guid id, [FromBody] TimeStamps timestamps)
{ {
// only update existing episodes // only update existing episodes
var rawItem = Plugin.Instance!.GetItem(id); var rawItem = Plugin.Instance!.GetItem(id);
if (rawItem is not Episode and not Movie) if (rawItem == null || rawItem is not Episode and not Movie)
{ {
return NotFound(); return NotFound();
} }
if (timestamps == null) if (timestamps?.Introduction.End > 0.0)
{ {
return NoContent(); var tr = new TimeRange(timestamps.Introduction.Start, timestamps.Introduction.End);
Plugin.Instance!.Intros[id] = new Segment(id, tr);
} }
var segmentTypes = new[] if (timestamps?.Credits.End > 0.0)
{ {
(AnalysisMode.Introduction, timestamps.Introduction), var cr = new TimeRange(timestamps.Credits.Start, timestamps.Credits.End);
(AnalysisMode.Credits, timestamps.Credits), Plugin.Instance!.Credits[id] = new Segment(id, cr);
(AnalysisMode.Recap, timestamps.Recap),
(AnalysisMode.Preview, timestamps.Preview)
};
foreach (var (mode, segment) in segmentTypes)
{
if (segment.Valid)
{
await Plugin.Instance!.UpdateTimestampAsync(segment, mode).ConfigureAwait(false);
}
} }
if (Plugin.Instance.Configuration.UpdateMediaSegments) Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction);
{ Plugin.Instance!.SaveTimestamps(AnalysisMode.Credits);
var episode = Plugin.Instance!.QueuedMediaItems[rawItem is Episode e ? e.SeasonId : rawItem.Id]
.FirstOrDefault(q => q.EpisodeId == rawItem.Id);
if (episode is not null)
{
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync([episode], cancellationToken).ConfigureAwait(false);
}
}
return NoContent(); return NoContent();
} }
@ -121,32 +103,20 @@ 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 is not Episode and not Movie) if (rawItem == null || rawItem is not Episode and not Movie)
{ {
return NotFound(); return NotFound();
} }
var times = new TimeStamps(); var times = new TimeStamps();
var segments = Plugin.Instance!.GetTimestamps(id); if (Plugin.Instance!.Intros.TryGetValue(id, out var introValue))
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
{ {
times.Introduction = introSegment; times.Introduction = introValue;
} }
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment)) if (Plugin.Instance!.Credits.TryGetValue(id, out var creditValue))
{ {
times.Credits = creditSegment; times.Credits = creditValue;
}
if (segments.TryGetValue(AnalysisMode.Recap, out var recapSegment))
{
times.Recap = recapSegment;
}
if (segments.TryGetValue(AnalysisMode.Preview, out var previewSegment))
{
times.Preview = previewSegment;
} }
return times; return times;
@ -161,66 +131,66 @@ 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 = GetIntros(id); var segments = new Dictionary<AnalysisMode, Intro>();
var result = new Dictionary<AnalysisMode, Intro>();
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment)) if (GetIntro(id, AnalysisMode.Introduction) is Intro intro)
{ {
result[AnalysisMode.Introduction] = introSegment; segments[AnalysisMode.Introduction] = intro;
} }
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment)) if (GetIntro(id, AnalysisMode.Credits) is Intro credits)
{ {
result[AnalysisMode.Credits] = creditSegment; segments[AnalysisMode.Credits] = credits;
} }
return result; return segments;
} }
/// <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>
internal static Dictionary<AnalysisMode, Intro> GetIntros(Guid id) private static Intro? GetIntro(Guid id, AnalysisMode mode)
{ {
var timestamps = Plugin.Instance!.GetTimestamps(id); try
var intros = new Dictionary<AnalysisMode, Intro>();
var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds;
var config = Plugin.Instance.Configuration;
foreach (var (mode, timestamp) in timestamps)
{ {
if (!timestamp.Valid) var timestamp = Plugin.GetIntroByMode(id, mode);
{
continue;
}
// Create new Intro to avoid mutating the original stored in dictionary // Operate on a copy to avoid mutating the original Intro object stored in the dictionary.
var segment = new Intro(timestamp); var segment = new Intro(timestamp);
// Calculate intro end time var config = Plugin.Instance!.Configuration;
segment.IntroEnd = runTime > 0 && runTime < segment.IntroEnd + 1 segment.IntroEnd = mode == AnalysisMode.Credits
? runTime ? 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 - MIN_REMAINING_TIME; segment.HideSkipPromptAt = segment.IntroEnd - 3;
} }
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 - MIN_REMAINING_TIME); segment.IntroEnd - 3);
} }
intros[mode] = segment; return segment;
} }
catch (KeyNotFoundException)
{
return null;
}
}
return intros; private static double GetAdjustedIntroEnd(Guid id, double segmentEnd, PluginConfiguration config)
{
var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds;
return runTime > 0 && runTime < segmentEnd + 1
? runTime
: segmentEnd - config.RemainingSecondsOfIntro;
} }
/// <summary> /// <summary>
@ -232,22 +202,24 @@ 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 async Task<ActionResult> ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false) public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false)
{ {
using var db = new IntroSkipperDbContext(Plugin.Instance!.DbPath); if (mode == AnalysisMode.Introduction)
var segments = await db.DbSegment
.Where(s => s.Type == mode)
.ToListAsync()
.ConfigureAwait(false);
db.DbSegment.RemoveRange(segments);
await db.SaveChangesAsync().ConfigureAwait(false);
if (eraseCache && mode is AnalysisMode.Introduction or AnalysisMode.Credits)
{ {
await Task.Run(() => FFmpegWrapper.DeleteCacheFiles(mode)).ConfigureAwait(false); Plugin.Instance!.Intros.Clear();
}
else if (mode == AnalysisMode.Credits)
{
Plugin.Instance!.Credits.Clear();
} }
if (eraseCache)
{
FFmpegWrapper.DeleteCacheFiles(mode);
}
Plugin.Instance!.EpisodeStates.Clear();
Plugin.Instance!.SaveTimestamps(mode);
return NoContent(); return NoContent();
} }
@ -262,13 +234,11 @@ public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateMan
{ {
var config = Plugin.Instance!.Configuration; var config = Plugin.Instance!.Configuration;
return new UserInterfaceConfiguration( return new UserInterfaceConfiguration(
config.SkipButtonEnabled, config.SkipButtonVisible,
config.SkipButtonIntroText, config.SkipButtonIntroText,
config.SkipButtonEndCreditsText, config.SkipButtonEndCreditsText,
config.AutoSkip, config.AutoSkip,
config.AutoSkipCredits, config.AutoSkipCredits,
config.AutoSkipRecap,
config.AutoSkipPreview,
config.ClientList); config.ClientList);
} }
} }

View File

@ -6,11 +6,7 @@ using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Net.Mime; using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Data; using IntroSkipper.Data;
using IntroSkipper.Db;
using IntroSkipper.Manager;
using MediaBrowser.Common.Api; using MediaBrowser.Common.Api;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -25,15 +21,13 @@ namespace IntroSkipper.Controllers;
/// Initializes a new instance of the <see cref="VisualizationController"/> class. /// Initializes a new instance of the <see cref="VisualizationController"/> class.
/// </remarks> /// </remarks>
/// <param name="logger">Logger.</param> /// <param name="logger">Logger.</param>
/// <param name="mediaSegmentUpdateManager">Media Segment Update Manager.</param>
[Authorize(Policy = Policies.RequiresElevation)] [Authorize(Policy = Policies.RequiresElevation)]
[ApiController] [ApiController]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[Route("Intros")] [Route("Intros")]
public class VisualizationController(ILogger<VisualizationController> logger, MediaSegmentUpdateManager mediaSegmentUpdateManager) : ControllerBase public class VisualizationController(ILogger<VisualizationController> logger) : ControllerBase
{ {
private readonly ILogger<VisualizationController> _logger = logger; private readonly ILogger<VisualizationController> _logger = logger;
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
/// <summary> /// <summary>
/// Returns all show names and seasons. /// Returns all show names and seasons.
@ -84,25 +78,49 @@ public class VisualizationController(ILogger<VisualizationController> logger, Me
} }
/// <summary> /// <summary>
/// Returns the analyzer actions for the provided season. /// Returns the ignore list 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("AnalyzerActions/{SeasonId}")] [HttpGet("IgnoreListSeason/{SeasonId}")]
public ActionResult<IReadOnlyDictionary<AnalysisMode, AnalyzerAction>> GetAnalyzerAction([FromRoute] Guid seasonId) public ActionResult<IgnoreListItem> GetIgnoreListSeason([FromRoute] Guid seasonId)
{ {
if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(seasonId)) if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(seasonId))
{ {
return NotFound(); return NotFound();
} }
var analyzerActions = new Dictionary<AnalysisMode, AnalyzerAction>(); if (!Plugin.Instance!.IgnoreList.TryGetValue(seasonId, out _))
foreach (var mode in Enum.GetValues<AnalysisMode>())
{ {
analyzerActions[mode] = Plugin.Instance!.GetAnalyzerAction(seasonId, mode); return new IgnoreListItem(seasonId);
} }
return Ok(analyzerActions); 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>
@ -158,14 +176,16 @@ public class VisualizationController(ILogger<VisualizationController> logger, Me
/// <param name="seriesId">Show ID.</param> /// <param name="seriesId">Show ID.</param>
/// <param name="seasonId">Season ID.</param> /// <param name="seasonId">Season ID.</param>
/// <param name="eraseCache">Erase cache.</param> /// <param name="eraseCache">Erase cache.</param>
/// <param name="cancellationToken">Cancellation Token.</param>
/// <response code="204">Season timestamps erased.</response> /// <response code="204">Season timestamps erased.</response>
/// <response code="404">Unable to find season in provided series.</response> /// <response code="404">Unable to find season in provided series.</response>
/// <returns>No content.</returns> /// <returns>No content.</returns>
[HttpDelete("Show/{SeriesId}/{SeasonId}")] [HttpDelete("Show/{SeriesId}/{SeasonId}")]
public async Task<ActionResult> EraseSeasonAsync([FromRoute] Guid seriesId, [FromRoute] Guid seasonId, [FromQuery] bool eraseCache = false, CancellationToken cancellationToken = default) public ActionResult EraseSeason([FromRoute] Guid seriesId, [FromRoute] Guid seasonId, [FromQuery] bool eraseCache = false)
{ {
var episodes = Plugin.Instance!.QueuedMediaItems[seasonId]; var episodes = Plugin.Instance!.QueuedMediaItems
.Where(kvp => kvp.Key == seasonId)
.SelectMany(kvp => kvp.Value.Where(e => e.SeriesId == seriesId))
.ToList();
if (episodes.Count == 0) if (episodes.Count == 0)
{ {
@ -174,55 +194,99 @@ public class VisualizationController(ILogger<VisualizationController> logger, Me
_logger.LogInformation("Erasing timestamps for series {SeriesId} season {SeasonId} at user request", seriesId, seasonId); _logger.LogInformation("Erasing timestamps for series {SeriesId} season {SeasonId} at user request", seriesId, seasonId);
try foreach (var e in episodes)
{ {
using var db = new IntroSkipperDbContext(Plugin.Instance!.DbPath); Plugin.Instance!.Intros.TryRemove(e.EpisodeId, out _);
Plugin.Instance!.Credits.TryRemove(e.EpisodeId, out _);
foreach (var episode in episodes) e.State.ResetStates();
if (eraseCache)
{ {
cancellationToken.ThrowIfCancellationRequested(); FFmpegWrapper.DeleteEpisodeCache(e.EpisodeId);
var existingSegments = db.DbSegment.Where(s => s.ItemId == episode.EpisodeId);
db.DbSegment.RemoveRange(existingSegments);
if (eraseCache)
{
await Task.Run(() => FFmpegWrapper.DeleteEpisodeCache(episode.EpisodeId), cancellationToken).ConfigureAwait(false);
}
} }
var seasonInfo = db.DbSeasonInfo.Where(s => s.SeasonId == seasonId);
foreach (var info in seasonInfo)
{
db.Entry(info).Property(s => s.EpisodeIds).CurrentValue = [];
}
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
if (Plugin.Instance.Configuration.UpdateMediaSegments)
{
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, cancellationToken).ConfigureAwait(false);
}
return NoContent();
}
catch (Exception ex)
{
return StatusCode(500, ex.Message);
} }
Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction | AnalysisMode.Credits);
return NoContent();
} }
/// <summary> /// <summary>
/// Updates the analyzer actions for the provided season. /// Updates the ignore list for the provided season.
/// </summary> /// </summary>
/// <param name="request">Update analyzer actions request.</param> /// <param name="ignoreListItem">New ignore list items.</param>
/// <param name="save">Save the ignore list.</param>
/// <returns>No content.</returns> /// <returns>No content.</returns>
[HttpPost("AnalyzerActions/UpdateSeason")] [HttpPost("IgnoreList/UpdateSeason")]
public async Task<ActionResult> UpdateAnalyzerActions([FromBody] UpdateAnalyzerActionsRequest request) public ActionResult UpdateIgnoreListSeason([FromBody] IgnoreListItem ignoreListItem, bool save = true)
{ {
await Plugin.Instance!.SetAnalyzerActionAsync(request.Id, request.AnalyzerActions).ConfigureAwait(false); if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(ignoreListItem.SeasonId))
{
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

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

View File

@ -1,35 +0,0 @@
// 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

@ -0,0 +1,35 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Data;
/// <summary>
/// Taken from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL.
/// </summary>
public enum EdlAction
{
/// <summary>
/// Do not create EDL files.
/// </summary>
None = -1,
/// <summary>
/// Completely remove the segment from playback as if it was never in the original video.
/// </summary>
Cut = 0,
/// <summary>
/// Mute audio, continue playback.
/// </summary>
Mute = 1,
/// <summary>
/// Inserts a new scene marker.
/// </summary>
SceneMarker = 2,
/// <summary>
/// Automatically skip once during playback.
/// </summary>
CommercialBreak = 3
}

View File

@ -0,0 +1,53 @@
// 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

@ -0,0 +1,92 @@
// 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

@ -25,16 +25,16 @@ public class QueuedEpisode
/// </summary> /// </summary>
public Guid EpisodeId { get; set; } public Guid EpisodeId { get; set; }
/// <summary>
/// Gets or sets the season id.
/// </summary>
public Guid SeasonId { get; set; }
/// <summary> /// <summary>
/// Gets or sets the series id. /// Gets or sets the series id.
/// </summary> /// </summary>
public Guid SeriesId { get; set; } public Guid SeriesId { get; set; }
/// <summary>
/// Gets the state of the episode.
/// </summary>
public EpisodeState State => Plugin.Instance!.GetState(EpisodeId);
/// <summary> /// <summary>
/// Gets or sets the full path to episode. /// Gets or sets the full path to episode.
/// </summary> /// </summary>
@ -55,11 +55,6 @@ public class QueuedEpisode
/// </summary> /// </summary>
public bool IsMovie { get; set; } public bool IsMovie { get; set; }
/// <summary>
/// Gets or sets a value indicating whether an episode has been analyzed.
/// </summary>
public bool IsAnalyzed { get; set; }
/// <summary> /// <summary>
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction at. /// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
/// </summary> /// </summary>

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.Globalization;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@ -33,8 +34,8 @@ public class Segment
public Segment(Guid episode) public Segment(Guid episode)
{ {
EpisodeId = episode; EpisodeId = episode;
Start = 0.0; Start = 0;
End = 0.0; End = 0;
} }
/// <summary> /// <summary>
@ -88,11 +89,29 @@ public class Segment
/// Gets a value indicating whether this introduction is valid or not. /// Gets a value indicating whether this introduction is valid or not.
/// Invalid results must not be returned through the API. /// Invalid results must not be returned through the API.
/// </summary> /// </summary>
public bool Valid => End > 0.0; public bool Valid => End > 0;
/// <summary> /// <summary>
/// Gets the duration of this intro. /// Gets the duration of this intro.
/// </summary> /// </summary>
[JsonIgnore] [JsonIgnore]
public double Duration => End - Start; public double Duration => End - Start;
/// <summary>
/// Convert this Intro object to a Kodi compatible EDL entry.
/// </summary>
/// <param name="action">User specified configuration EDL action.</param>
/// <returns>String.</returns>
public string ToEdl(EdlAction action)
{
if (action == EdlAction.None)
{
throw new ArgumentException("Cannot serialize an EdlAction of None");
}
var start = Math.Round(Start, 2);
var end = Math.Round(End, 2);
return string.Format(CultureInfo.InvariantCulture, "{0} {1} {2}", start, end, (int)action);
}
} }

View File

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

View File

@ -1,21 +0,0 @@
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

@ -1,59 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using 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>
/// <param name="episodeIds">Episode IDs.</param>
public DbSeasonInfo(Guid seasonId, AnalysisMode mode, AnalyzerAction action, IEnumerable<Guid>? episodeIds = null)
{
SeasonId = seasonId;
Type = mode;
Action = action;
EpisodeIds = episodeIds ?? [];
}
/// <summary>
/// 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; }
/// <summary>
/// Gets the season number.
/// </summary>
public IEnumerable<Guid> EpisodeIds { get; private set; } = [];
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,218 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Serialization;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using IntroSkipper.Db;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Updates;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.Helper;
internal static class LegacyMigrations
{
public static void MigrateAll(
Plugin plugin,
IServerConfigurationManager serverConfiguration,
ILogger logger,
IApplicationPaths applicationPaths)
{
var pluginDirName = "introskipper";
var introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml");
var creditsPath = Path.Join(applicationPaths.DataPath, pluginDirName, "credits.xml");
// Migrate XML files from XMLSchema to DataContract
XmlSerializationHelper.MigrateXML(introPath);
XmlSerializationHelper.MigrateXML(creditsPath);
MigrateConfig(plugin, applicationPaths.PluginConfigurationsPath, logger);
MigrateRepoUrl(plugin, serverConfiguration, logger);
InjectSkipButton(plugin, applicationPaths.WebPath, logger);
RestoreTimestamps(plugin.DbPath, introPath, creditsPath);
}
private static void MigrateConfig(Plugin plugin, string pluginConfigurationsPath, ILogger logger)
{
var oldConfigFile = Path.Join(pluginConfigurationsPath, "ConfusedPolarBear.Plugin.IntroSkipper.xml");
if (File.Exists(oldConfigFile))
{
try
{
XmlSerializer serializer = new XmlSerializer(typeof(PluginConfiguration));
using FileStream fileStream = new FileStream(oldConfigFile, FileMode.Open);
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit, // Disable DTD processing
XmlResolver = null // Disable the XmlResolver
};
using var reader = XmlReader.Create(fileStream, settings);
if (serializer.Deserialize(reader) is PluginConfiguration oldConfig)
{
plugin.UpdateConfiguration(oldConfig);
fileStream.Close();
File.Delete(oldConfigFile);
}
}
catch (Exception ex)
{
// Handle exceptions, such as file not found, deserialization errors, etc.
logger.LogWarning("Failed to migrate from the ConfusedPolarBear Config {Exception}", ex);
}
}
}
private static void MigrateRepoUrl(Plugin plugin, IServerConfigurationManager serverConfiguration, ILogger logger)
{
try
{
List<string> oldRepos =
[
"https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/manifest.json",
"https://raw.githubusercontent.com/jumoog/intro-skipper/master/manifest.json",
"https://manifest.intro-skipper.workers.dev/manifest.json"
];
var config = serverConfiguration.Configuration;
var pluginRepositories = config.PluginRepositories.ToList();
if (pluginRepositories.Exists(repo => repo.Url != null && oldRepos.Contains(repo.Url)))
{
pluginRepositories.RemoveAll(repo => repo.Url != null && oldRepos.Contains(repo.Url));
if (!pluginRepositories.Exists(repo => repo.Url == "https://manifest.intro-skipper.org/manifest.json") && plugin.Configuration.OverrideManifestUrl)
{
pluginRepositories.Add(new RepositoryInfo
{
Name = "intro skipper (automatically migrated by plugin)",
Url = "https://manifest.intro-skipper.org/manifest.json",
Enabled = true,
});
}
config.PluginRepositories = [.. pluginRepositories];
serverConfiguration.SaveConfiguration();
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred while migrating repo URL");
}
}
private static void InjectSkipButton(Plugin plugin, string webPath, ILogger logger)
{
string pattern = @"<script src=""configurationpage\?name=skip-intro-button\.js.*<\/script>";
string indexPath = Path.Join(webPath, "index.html");
// Check if we can actually access the file
bool canAccessFile = false;
try
{
if (File.Exists(indexPath))
{
using var fs = File.Open(indexPath, FileMode.Open, FileAccess.ReadWrite);
canAccessFile = true;
}
}
catch (Exception)
{
// If skip button is disabled and we can't access the file, just return silently
if (!plugin.Configuration.SkipButtonEnabled)
{
logger.LogDebug("Skip button disabled and no permission to access index.html. Assuming its a fresh install.");
return;
}
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
logger.LogError("Failed to add skip button to web interface. See https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible for the most common issues.");
return;
}
if (!canAccessFile)
{
logger.LogDebug("Jellyfin running as nowebclient");
return;
}
try
{
logger.LogInformation("Reading index.html from {Path}", indexPath);
string contents = File.ReadAllText(indexPath);
if (!plugin.Configuration.SkipButtonEnabled)
{
if (!Regex.IsMatch(contents, pattern, RegexOptions.IgnoreCase))
{
logger.LogDebug("Skip button not found. Assuming its a fresh install.");
return;
}
logger.LogInformation("Skip button found. Removing the Skip button.");
contents = Regex.Replace(contents, pattern, string.Empty, RegexOptions.IgnoreCase);
File.WriteAllText(indexPath, contents);
return;
}
string scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js&release=" + plugin.GetType().Assembly.GetName().Version + "\"></script>";
if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("The skip button has already been injected.");
return;
}
contents = Regex.Replace(contents, pattern, string.Empty, RegexOptions.IgnoreCase);
Regex headEnd = new Regex(@"</head>", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
File.WriteAllText(indexPath, contents);
logger.LogInformation("Skip button added successfully.");
}
catch (UnauthorizedAccessException)
{
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
logger.LogError("Failed to add skip button to web interface. See https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible for the most common issues.");
}
catch (IOException)
{
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
logger.LogError("Failed to add skip button to web interface. See https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible for the most common issues.");
}
}
private static void RestoreTimestamps(string dbPath, string introPath, string creditsPath)
{
using var db = new IntroSkipperDbContext(dbPath);
// Import intros
if (File.Exists(introPath))
{
var introList = XmlSerializationHelper.DeserializeFromXml<Segment>(introPath);
foreach (var intro in introList)
{
db.DbSegment.Add(new DbSegment(intro, AnalysisMode.Introduction));
}
}
// Import credits
if (File.Exists(creditsPath))
{
var creditList = XmlSerializationHelper.DeserializeFromXml<Segment>(creditsPath);
foreach (var credit in creditList)
{
db.DbSegment.Add(new DbSegment(credit, AnalysisMode.Credits));
}
}
db.SaveChanges();
File.Delete(introPath);
File.Delete(creditsPath);
}
}

View File

@ -1,42 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System.IO;
using System.Runtime.InteropServices;
namespace IntroSkipper.Helper
{
/// <summary>
/// Provides methods to determine the operating system.
/// </summary>
public static class OperatingSystem
{
/// <summary>
/// Determines if the current operating system is Windows.
/// </summary>
/// <returns>True if the current operating system is Windows; otherwise, false.</returns>
public static bool IsWindows() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
/// <summary>
/// Determines if the current operating system is macOS.
/// </summary>
/// <returns>True if the current operating system is macOS; otherwise, false.</returns>
public static bool IsMacOS() =>
RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
/// <summary>
/// Determines if the current operating system is Linux.
/// </summary>
/// <returns>True if the current operating system is Linux; otherwise, false.</returns>
public static bool IsLinux() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
/// <summary>
/// Determines if the current environment is running in Docker.
/// </summary>
/// <returns>True if running in a Docker container; otherwise, false.</returns>
public static bool IsDocker() =>
File.Exists("/.dockerenv") || File.Exists("/run/.containerenv");
}
}

View File

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

View File

@ -2,8 +2,8 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>IntroSkipper</RootNamespace> <RootNamespace>IntroSkipper</RootNamespace>
<AssemblyVersion>1.10.10.11</AssemblyVersion> <AssemblyVersion>1.10.9.2</AssemblyVersion>
<FileVersion>1.10.10.11</FileVersion> <FileVersion>1.10.9.2</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -11,10 +11,8 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.10.*-*" /> <PackageReference Include="Jellyfin.Controller" Version="10.9.*" />
<PackageReference Include="Jellyfin.Model" Version="10.10.*-*" /> <PackageReference Include="Jellyfin.Model" Version="10.9.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="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" />
@ -25,4 +23,8 @@
<EmbeddedResource Include="Configuration\visualizer.js" /> <EmbeddedResource Include="Configuration\visualizer.js" />
<EmbeddedResource Include="Configuration\inject.js" /> <EmbeddedResource Include="Configuration\inject.js" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Manager\" />
<Folder Include="Services\" />
</ItemGroup>
</Project> </Project>

View File

@ -1,25 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.002.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntroSkipper", "IntroSkipper.csproj", "{BF8E8662-3409-439D-95BA-FC918FFBBDB4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{BF8E8662-3409-439D-95BA-FC918FFBBDB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BF8E8662-3409-439D-95BA-FC918FFBBDB4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BF8E8662-3409-439D-95BA-FC918FFBBDB4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BF8E8662-3409-439D-95BA-FC918FFBBDB4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8BD9D646-8C5E-41FA-8C7A-72749524B7D7}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,119 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.IO;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
namespace IntroSkipper;
/// <summary>
/// Update EDL files associated with a list of episodes.
/// </summary>
public static class EdlManager
{
private static ILogger? _logger;
/// <summary>
/// Initialize EDLManager with a logger.
/// </summary>
/// <param name="logger">ILogger.</param>
public static void Initialize(ILogger logger)
{
_logger = logger;
}
/// <summary>
/// Logs the configuration that will be used during EDL file creation.
/// </summary>
public static void LogConfiguration()
{
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);
}
/// <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)
{
_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);
}
}
/// <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

@ -1,68 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Data;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Model;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.Manager
{
/// <summary>
/// Initializes a new instance of the <see cref="MediaSegmentUpdateManager" /> class.
/// </summary>
/// <param name="mediaSegmentManager">MediaSegmentManager.</param>
/// <param name="logger">logger.</param>
/// <param name="segmentProvider">segmentProvider.</param>
public class MediaSegmentUpdateManager(IMediaSegmentManager mediaSegmentManager, ILogger<MediaSegmentUpdateManager> logger, IMediaSegmentProvider segmentProvider)
{
private readonly IMediaSegmentManager _mediaSegmentManager = mediaSegmentManager;
private readonly ILogger<MediaSegmentUpdateManager> _logger = logger;
private readonly IMediaSegmentProvider _segmentProvider = segmentProvider;
private readonly string _id = Plugin.Instance!.Name.ToLowerInvariant()
.GetMD5()
.ToString("N", CultureInfo.InvariantCulture);
/// <summary>
/// Updates all media items in a List.
/// </summary>
/// <param name="episodes">Queued media items.</param>
/// <param name="cancellationToken">CancellationToken.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task UpdateMediaSegmentsAsync(IReadOnlyList<QueuedEpisode> episodes, CancellationToken cancellationToken)
{
foreach (var episode in episodes)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var existingSegments = await _mediaSegmentManager.GetSegmentsAsync(episode.EpisodeId, null, false).ConfigureAwait(false);
await Task.WhenAll(existingSegments.Select(s => _mediaSegmentManager.DeleteSegmentAsync(s.Id))).ConfigureAwait(false);
var newSegments = await _segmentProvider.GetMediaSegments(new MediaSegmentGenerationRequest { ItemId = episode.EpisodeId }, cancellationToken).ConfigureAwait(false);
if (newSegments.Count == 0)
{
_logger.LogDebug("No segments found for episode {EpisodeId}", episode.EpisodeId);
continue;
}
await Task.WhenAll(newSegments.Select(s => _mediaSegmentManager.CreateSegmentAsync(s, _id))).ConfigureAwait(false);
_logger.LogDebug("Updated {SegmentCount} segments for episode {EpisodeId}", newSegments.Count, episode.EpisodeId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing episode {EpisodeId}", episode.EpisodeId);
}
}
}
}
}

View File

@ -14,325 +14,326 @@ using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace IntroSkipper.Manager namespace 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;
private bool _analyzeMovies;
/// <summary> /// <summary>
/// Manages enqueuing library items for analysis. /// Gets all media items on the server.
/// </summary> /// </summary>
/// <remarks> /// <returns>Queued media items.</returns>
/// Initializes a new instance of the <see cref="QueueManager"/> class. public IReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems()
/// </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; Plugin.Instance!.TotalQueued = 0;
private readonly ILogger<QueueManager> _logger = logger;
private readonly Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes = [];
private double _analysisPercent;
private List<string> _selectedLibraries = [];
private bool _selectAllLibraries;
private bool _analyzeMovies;
/// <summary> LoadAnalysisSettings();
/// Gets all media items on the server.
/// </summary> // For all selected libraries, enqueue all contained episodes.
/// <returns>Queued media items.</returns> foreach (var folder in _libraryManager.GetVirtualFolders())
public IReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems()
{ {
Plugin.Instance!.TotalQueued = 0; // If libraries have been selected for analysis, ensure this library was selected.
if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name))
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. _logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name);
if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name)) continue;
{
_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);
}
} }
Plugin.Instance.TotalSeasons = _queuedEpisodes.Count; _logger.LogInformation("Running enqueue of items in library {Name}", folder.Name);
Plugin.Instance.QueuedMediaItems.Clear();
foreach (var kvp in _queuedEpisodes) // Some virtual folders don't have a proper item id.
if (!Guid.TryParse(folder.ItemId, out var folderId))
{ {
Plugin.Instance.QueuedMediaItems.TryAdd(kvp.Key, kvp.Value); continue;
} }
return _queuedEpisodes; try
{
QueueLibraryContents(folderId);
}
catch (Exception ex)
{
_logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex);
}
} }
/// <summary> Plugin.Instance.TotalSeasons = _queuedEpisodes.Count;
/// Loads the list of libraries which have been selected for analysis and the minimum intro duration. Plugin.Instance.QueuedMediaItems.Clear();
/// Settings which have been modified from the defaults are logged. foreach (var kvp in _queuedEpisodes)
/// </summary>
private void LoadAnalysisSettings()
{ {
var config = Plugin.Instance!.Configuration; Plugin.Instance.QueuedMediaItems.TryAdd(kvp.Key, kvp.Value);
}
// Store the analysis percent return _queuedEpisodes;
_analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100; }
_selectAllLibraries = config.SelectAllLibraries; /// <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;
_analyzeMovies = config.AnalyzeMovies; // Store the analysis percent
_analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100;
if (!_selectAllLibraries) _selectAllLibraries = config.SelectAllLibraries;
_analyzeMovies = config.AnalyzeMovies;
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, BaseItemKind.Movie],
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 Episode episode)
{ {
// Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries. QueueEpisode(episode);
_selectedLibraries = [.. config.SelectedLibraries.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; }
else if (_analyzeMovies && item is Movie movie)
// If any libraries have been selected for analysis, log their names. {
_logger.LogInformation("Limiting analysis to the following libraries: {Selected}", _selectedLibraries); QueueMovie(movie);
} }
else else
{ {
_logger.LogDebug("Not limiting analysis by library name"); _logger.LogDebug("Item {Name} is not an episode or movie", item.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("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.LogDebug("Constructing anonymous internal query"); _logger.LogWarning(
"Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin",
episode.Name,
episode.SeriesName,
episode.Id);
return;
}
var query = new InternalItemsQuery // 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);
var maxCreditsDuration = Math.Min(
duration >= 5 * 60 ? duration * _analysisPercent : duration,
60 * pluginInstance.Configuration.MaximumCreditsDuration);
// Queue the episode for analysis
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 void QueueMovie(Movie movie)
{
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");
if (string.IsNullOrEmpty(movie.Path))
{
_logger.LogWarning(
"Not queuing movie \"{Name}\" ({Id}) as no path was provided by Jellyfin",
movie.Name,
movie.Id);
return;
}
// Allocate a new list for each Movie
_queuedEpisodes.TryAdd(movie.Id, []);
var duration = TimeSpan.FromTicks(movie.RunTimeTicks ?? 0).TotalSeconds;
_queuedEpisodes[movie.Id].Add(new QueuedEpisode
{
SeriesName = movie.Name,
SeriesId = movie.Id,
EpisodeId = movie.Id,
Name = movie.Name,
Path = movie.Path,
Duration = Convert.ToInt32(duration),
IsMovie = true
});
pluginInstance.TotalQueued++;
}
private Guid GetSeasonId(Episode episode)
{
if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special
{
foreach (var kvp in _queuedEpisodes)
{ {
// Order by series name, season, and then episode number so that status updates are logged in order var first = kvp.Value.FirstOrDefault();
ParentId = id, if (first?.SeriesId == episode.SeriesId &&
OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),], first.SeasonNumber == episode.AiredSeasonNumber)
IncludeItemTypes = [BaseItemKind.Episode, BaseItemKind.Movie],
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 Episode episode)
{ {
QueueEpisode(episode); return kvp.Key;
}
else if (item is Movie movie)
{
if (_analyzeMovies)
{
QueueMovie(movie);
}
}
else
{
_logger.LogDebug("Item {Name} is not an episode or movie", item.Name);
} }
} }
_logger.LogDebug("Queued {Count} episodes", items.Count);
} }
private void QueueEpisode(Episode episode) 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)
{ {
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null"); try
if (string.IsNullOrEmpty(episode.Path))
{ {
_logger.LogWarning( var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
"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 if (!File.Exists(path))
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);
var maxCreditsDuration = Math.Min(
duration >= 5 * 60 ? duration * _analysisPercent : duration,
60 * pluginInstance.Configuration.MaximumCreditsDuration);
// Queue the episode for analysis
seasonEpisodes.Add(new QueuedEpisode
{
SeriesName = episode.SeriesName,
SeasonNumber = episode.AiredSeasonNumber ?? 0,
SeriesId = episode.SeriesId,
SeasonId = episode.SeasonId,
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 void QueueMovie(Movie movie)
{
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");
if (string.IsNullOrEmpty(movie.Path))
{
_logger.LogWarning(
"Not queuing movie \"{Name}\" ({Id}) as no path was provided by Jellyfin",
movie.Name,
movie.Id);
return;
}
// Allocate a new list for each Movie
_queuedEpisodes.TryAdd(movie.Id, []);
var duration = TimeSpan.FromTicks(movie.RunTimeTicks ?? 0).TotalSeconds;
_queuedEpisodes[movie.Id].Add(new QueuedEpisode
{
SeriesName = movie.Name,
SeriesId = movie.Id,
SeasonId = movie.Id,
EpisodeId = movie.Id,
Name = movie.Name,
Path = movie.Path,
Duration = Convert.ToInt32(duration),
CreditsFingerprintStart = Convert.ToInt32(duration - pluginInstance.Configuration.MaximumMovieCreditsDuration),
IsMovie = true
});
pluginInstance.TotalQueued++;
}
private Guid GetSeasonId(Episode episode)
{
if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special
{
foreach (var kvp in _queuedEpisodes)
{ {
var first = kvp.Value.FirstOrDefault(); continue;
if (first?.SeriesId == episode.SeriesId &&
first.SeasonNumber == episode.AiredSeasonNumber)
{
return kvp.Key;
}
} }
}
return episode.SeasonId; verified.Add(candidate);
}
/// <summary> foreach (var mode in modes)
/// 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>
internal (IReadOnlyList<QueuedEpisode> QueuedEpisodes, IReadOnlyCollection<AnalysisMode> RequiredModes)
VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<AnalysisMode> modes)
{
var verified = new List<QueuedEpisode>();
var requiredModes = new HashSet<AnalysisMode>();
var episodeIds = Plugin.Instance!.GetEpisodeIds(candidates[0].SeasonId);
foreach (var candidate in candidates)
{
try
{ {
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId); if (candidate.State.IsAnalyzed(mode) || candidate.State.IsBlacklisted(mode))
if (!File.Exists(path))
{ {
continue; continue;
} }
verified.Add(candidate); bool isAnalyzed = mode == AnalysisMode.Introduction
? Plugin.Instance!.Intros.ContainsKey(candidate.EpisodeId)
: Plugin.Instance!.Credits.ContainsKey(candidate.EpisodeId);
foreach (var mode in modes) if (isAnalyzed)
{ {
if (!episodeIds.TryGetValue(mode, out var ids) || !ids.Contains(candidate.EpisodeId) || Plugin.Instance!.AnalyzeAgain) candidate.State.SetAnalyzed(mode, true);
{ }
requiredModes.Add(mode); else
} {
reqModes.Add(mode);
} }
} }
catch (Exception ex)
{
_logger.LogDebug(
"Skipping analysis of {Name} ({Id}): {Exception}",
candidate.Name,
candidate.EpisodeId,
ex);
}
} }
catch (Exception ex)
return (verified, requiredModes); {
_logger.LogDebug(
"Skipping analysis of {Name} ({Id}): {Exception}",
candidate.Name,
candidate.EpisodeId,
ex);
}
} }
return (verified, reqModes);
} }
} }

View File

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

View File

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

View File

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

View File

@ -6,11 +6,12 @@ using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Serialization;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Data; using IntroSkipper.Data;
using IntroSkipper.Db; using MediaBrowser.Common;
using IntroSkipper.Helper;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
@ -20,7 +21,7 @@ using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using Microsoft.EntityFrameworkCore; using MediaBrowser.Model.Updates;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace IntroSkipper; namespace IntroSkipper;
@ -30,14 +31,20 @@ 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 ILogger<Plugin> _logger; private readonly ILogger<Plugin> _logger;
private readonly string _dbPath; private readonly string _introPath;
private readonly string _creditsPath;
private string _ignorelistPath;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class. /// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary> /// </summary>
/// <param name="applicationHost">Application host.</param>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param> /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
/// <param name="serverConfiguration">Server configuration manager.</param> /// <param name="serverConfiguration">Server configuration manager.</param>
@ -45,6 +52,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <param name="itemRepository">Item repository.</param> /// <param name="itemRepository">Item repository.</param>
/// <param name="logger">Logger.</param> /// <param name="logger">Logger.</param>
public Plugin( public Plugin(
IApplicationHost applicationHost,
IApplicationPaths applicationPaths, IApplicationPaths applicationPaths,
IXmlSerializer xmlSerializer, IXmlSerializer xmlSerializer,
IServerConfigurationManager serverConfiguration, IServerConfigurationManager serverConfiguration,
@ -55,6 +63,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{ {
Instance = this; Instance = this;
_applicationHost = applicationHost;
_libraryManager = libraryManager; _libraryManager = libraryManager;
_itemRepository = itemRepository; _itemRepository = itemRepository;
_logger = logger; _logger = logger;
@ -68,8 +77,9 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
var introsDirectory = Path.Join(applicationPaths.DataPath, pluginDirName); var introsDirectory = Path.Join(applicationPaths.DataPath, pluginDirName);
FingerprintCachePath = Path.Join(introsDirectory, pluginCachePath); FingerprintCachePath = Path.Join(introsDirectory, pluginCachePath);
_introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml");
_dbPath = Path.Join(applicationPaths.DataPath, pluginDirName, "introskipper.db"); _creditsPath = Path.Join(applicationPaths.DataPath, pluginDirName, "credits.xml");
_ignorelistPath = Path.Join(applicationPaths.DataPath, pluginDirName, "ignorelist.xml");
// Create the base & cache directories (if needed). // Create the base & cache directories (if needed).
if (!Directory.Exists(FingerprintCachePath)) if (!Directory.Exists(FingerprintCachePath))
@ -77,44 +87,103 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
Directory.CreateDirectory(FingerprintCachePath); Directory.CreateDirectory(FingerprintCachePath);
} }
// Initialize database, restore timestamps if available. // migrate from XMLSchema to DataContract
XmlSerializationHelper.MigrateXML(_introPath);
XmlSerializationHelper.MigrateXML(_creditsPath);
var oldConfigFile = Path.Join(applicationPaths.PluginConfigurationsPath, "ConfusedPolarBear.Plugin.IntroSkipper.xml");
if (File.Exists(oldConfigFile))
{
try
{
XmlSerializer serializer = new XmlSerializer(typeof(PluginConfiguration));
using (FileStream fileStream = new FileStream(oldConfigFile, FileMode.Open))
{
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit, // Disable DTD processing
XmlResolver = null // Disable the XmlResolver
};
using (var reader = XmlReader.Create(fileStream, settings))
{
if (serializer.Deserialize(reader) is PluginConfiguration oldConfig)
{
Instance.UpdateConfiguration(oldConfig);
File.Delete(oldConfigFile);
}
}
}
}
catch (Exception ex)
{
// Handle exceptions, such as file not found, deserialization errors, etc.
_logger.LogWarning("Something stupid happened: {Exception}", ex);
}
}
MigrateRepoUrl(serverConfiguration);
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
try try
{ {
using var db = new IntroSkipperDbContext(_dbPath); RestoreTimestamps();
db.ApplyMigrations();
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning("Error initializing database: {Exception}", ex); _logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex);
} }
try try
{ {
LegacyMigrations.MigrateAll(this, serverConfiguration, logger, applicationPaths); LoadIgnoreList();
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError("Failed to perform migrations. Error: {Error}", ex); _logger.LogWarning("Unable to load ignore list: {Exception}", ex);
}
// Inject the skip intro button code into the web interface.
try
{
InjectSkipButton(applicationPaths.WebPath);
}
catch (Exception ex)
{
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
_logger.LogError("Failed to add skip button to web interface. See https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible for the most common issues. Error: {Error}", ex);
} }
FFmpegWrapper.CheckFFmpegVersion(); FFmpegWrapper.CheckFFmpegVersion();
} }
/// <summary> /// <summary>
/// Gets the path to the database. /// Gets the results of fingerprinting all episodes.
/// </summary> /// </summary>
public string DbPath => _dbPath; public ConcurrentDictionary<Guid, Segment> Intros { get; } = new();
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to analyze again. /// Gets all discovered ending credits.
/// </summary> /// </summary>
public bool AnalyzeAgain { get; set; } 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>
@ -146,6 +215,111 @@ 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>
/// Restore previous analysis results from disk.
/// </summary>
public void RestoreTimestamps()
{
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);
foreach (var intro in introList)
{
Instance!.Intros.TryAdd(intro.EpisodeId, intro);
}
}
if (File.Exists(_creditsPath))
{
var creditList = XmlSerializationHelper.DeserializeFromXml<Segment>(_creditsPath);
foreach (var credit in creditList)
{
Instance!.Credits.TryAdd(credit.EpisodeId, credit);
}
}
}
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages() public IEnumerable<PluginPageInfo> GetPages()
{ {
@ -169,12 +343,25 @@ 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;
} }
internal ICollection<Folder> GetCollectionFolders(Guid id) internal IReadOnlyList<Folder> GetCollectionFolders(Guid id)
{ {
var item = GetItem(id); var item = GetItem(id);
return item is not null ? _libraryManager.GetCollectionFolders(item) : []; return item is not null ? _libraryManager.GetCollectionFolders(item) : [];
@ -216,111 +403,186 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
return _itemRepository.GetChapters(item); return _itemRepository.GetChapters(item);
} }
internal async Task UpdateTimestampAsync(Segment segment, AnalysisMode mode) /// <summary>
{ /// Gets the state for this item.
using var db = new IntroSkipperDbContext(_dbPath); /// </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)
{
if (mode == AnalysisMode.Introduction)
{
Instance!.Intros.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value);
}
else if (mode == AnalysisMode.Credits)
{
Instance!.Credits.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value);
}
}
SaveTimestamps(mode);
}
internal void CleanTimestamps(HashSet<Guid> validEpisodeIds)
{
var allKeys = new HashSet<Guid>(Instance!.Intros.Keys);
allKeys.UnionWith(Instance!.Credits.Keys);
foreach (var key in allKeys)
{
if (!validEpisodeIds.Contains(key))
{
Instance!.Intros.TryRemove(key, out _);
Instance!.Credits.TryRemove(key, out _);
}
}
SaveTimestamps(AnalysisMode.Introduction);
SaveTimestamps(AnalysisMode.Credits);
}
private void MigrateRepoUrl(IServerConfigurationManager serverConfiguration)
{
try try
{ {
var existing = await db.DbSegment List<string> oldRepos =
.FirstOrDefaultAsync(s => s.ItemId == segment.EpisodeId && s.Type == mode) [
.ConfigureAwait(false); "https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/manifest.json",
"https://raw.githubusercontent.com/jumoog/intro-skipper/master/manifest.json",
"https://manifest.intro-skipper.workers.dev/manifest.json"
];
// Access the current server configuration
var config = serverConfiguration.Configuration;
var dbSegment = new DbSegment(segment, mode); // Get the list of current plugin repositories
if (existing is not null) var pluginRepositories = config.PluginRepositories.ToList();
{
db.Entry(existing).CurrentValues.SetValues(dbSegment);
}
else
{
db.DbSegment.Add(dbSegment);
}
await db.SaveChangesAsync().ConfigureAwait(false); // check if old plugins exits
if (pluginRepositories.Exists(repo => repo.Url != null && oldRepos.Contains(repo.Url)))
{
// remove all old plugins
pluginRepositories.RemoveAll(repo => repo.Url != null && oldRepos.Contains(repo.Url));
// Add repository only if it does not exit and the OverideManifestUrl Option is activated
if (!pluginRepositories.Exists(repo => repo.Url == "https://manifest.intro-skipper.org/manifest.json") && Instance!.Configuration.OverrideManifestUrl)
{
// Add the new repository to the list
pluginRepositories.Add(new RepositoryInfo
{
Name = "intro skipper (automatically migrated by plugin)",
Url = "https://manifest.intro-skipper.org/manifest.json",
Enabled = true,
});
}
// Update the configuration with the new repository list
config.PluginRepositories = [.. pluginRepositories];
// Save the updated configuration
serverConfiguration.SaveConfiguration();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to update timestamp for episode {EpisodeId}", segment.EpisodeId); _logger.LogError(ex, "Error occurred while migrating repo URL");
throw;
} }
} }
internal IReadOnlyDictionary<AnalysisMode, Segment> GetTimestamps(Guid id) /// <summary>
/// Inject the skip button script into the web interface.
/// </summary>
/// <param name="webPath">Full path to index.html.</param>
private void InjectSkipButton(string webPath)
{ {
using var db = new IntroSkipperDbContext(_dbPath); string searchPattern = "dashboard-dashboard.*.chunk.js";
return db.DbSegment.Where(s => s.ItemId == id) string[] filePaths = Directory.GetFiles(webPath, searchPattern, SearchOption.TopDirectoryOnly);
.ToDictionary(s => s.Type, s => s.ToSegment()); string pattern = @"buildVersion""\)\.innerText=""(?<buildVersion>\d+\.\d+\.\d+)"",.*?webVersion""\)\.innerText=""(?<webVersion>\d+\.\d+\.\d+)";
} string webVersionString = "unknown";
// Create a Regex object
Regex regex = new Regex(pattern);
internal async Task CleanTimestamps(IEnumerable<Guid> episodeIds) // should be only one file but this safer
{ foreach (var file in filePaths)
using var db = new IntroSkipperDbContext(_dbPath);
db.DbSegment.RemoveRange(db.DbSegment
.Where(s => !episodeIds.Contains(s.ItemId)));
await db.SaveChangesAsync().ConfigureAwait(false);
}
internal async Task SetAnalyzerActionAsync(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 (existingEntries.TryGetValue(mode, out var existing)) string dashBoardText = File.ReadAllText(file);
// Perform the match
Match match = regex.Match(dashBoardText);
// search for buildVersion and webVersion
if (match.Success)
{ {
db.Entry(existing).Property(s => s.Action).CurrentValue = action; webVersionString = match.Groups["webVersion"].Value;
} _logger.LogInformation("Found jellyfin-web <{WebVersion}>", webVersionString);
else break;
{
db.DbSeasonInfo.Add(new DbSeasonInfo(id, mode, action));
} }
} }
await db.SaveChangesAsync().ConfigureAwait(false); if (webVersionString != "unknown")
}
internal async Task SetEpisodeIdsAsync(Guid id, AnalysisMode mode, IEnumerable<Guid> episodeIds)
{
using var db = new IntroSkipperDbContext(_dbPath);
var seasonInfo = db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode);
if (seasonInfo is null)
{ {
seasonInfo = new DbSeasonInfo(id, mode, AnalyzerAction.Default, episodeIds); // append Revision
db.DbSeasonInfo.Add(seasonInfo); webVersionString += ".0";
} if (Version.TryParse(webVersionString, out var webversion))
else {
{ if (_applicationHost.ApplicationVersion != webversion)
db.Entry(seasonInfo).Property(s => s.EpisodeIds).CurrentValue = episodeIds; {
_logger.LogWarning("The jellyfin-web <{WebVersion}> NOT compatible with Jellyfin <{JellyfinVersion}>", webVersionString, _applicationHost.ApplicationVersion);
}
else
{
_logger.LogInformation("The jellyfin-web <{WebVersion}> compatible with Jellyfin <{JellyfinVersion}>", webVersionString, _applicationHost.ApplicationVersion);
}
}
} }
await db.SaveChangesAsync().ConfigureAwait(false); // search for controllers/playback/video/index.html
} searchPattern = "playback-video-index-html.*.chunk.js";
filePaths = Directory.GetFiles(webPath, searchPattern, SearchOption.TopDirectoryOnly);
internal IReadOnlyDictionary<AnalysisMode, IEnumerable<Guid>> GetEpisodeIds(Guid id) // should be only one file but this safer
{ foreach (var file in filePaths)
using var db = new IntroSkipperDbContext(_dbPath); {
return db.DbSeasonInfo.Where(s => s.SeasonId == id) // search for class btnSkipIntro
.ToDictionary(s => s.Type, s => s.EpisodeIds); if (File.ReadAllText(file).Contains("btnSkipIntro", StringComparison.OrdinalIgnoreCase))
} {
_logger.LogInformation("Found a modified version of jellyfin-web with built-in skip button support.");
return;
}
}
internal AnalyzerAction GetAnalyzerAction(Guid id, AnalysisMode mode) // Inject the skip intro button code into the web interface.
{ string indexPath = Path.Join(webPath, "index.html");
using var db = new IntroSkipperDbContext(_dbPath);
return db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode)?.Action ?? AnalyzerAction.Default;
}
internal async Task CleanSeasonInfoAsync(IEnumerable<Guid> ids) // Parts of this code are based off of JellyScrub's script injection code.
{ // https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/JellyscrubPlugin.cs#L38
using var db = new IntroSkipperDbContext(_dbPath);
var obsoleteSeasons = await db.DbSeasonInfo _logger.LogDebug("Reading index.html from {Path}", indexPath);
.Where(s => !ids.Contains(s.SeasonId)) string contents = File.ReadAllText(indexPath);
.ToListAsync().ConfigureAwait(false);
db.DbSeasonInfo.RemoveRange(obsoleteSeasons); // change URL with every relase to prevent the Browers from caching
await db.SaveChangesAsync().ConfigureAwait(false); string scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js&release=" + GetType().Assembly.GetName().Version + "\"></script>";
// Only inject the script tag once
if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("The skip button has already been injected.");
return;
}
// remove old version if necessary
pattern = @"<script src=""configurationpage\?name=skip-intro-button\.js.*<\/script>";
contents = Regex.Replace(contents, pattern, string.Empty, RegexOptions.IgnoreCase);
// Inject a link to the script at the end of the <head> section.
// A regex is used here to ensure the replacement is only done once.
Regex headEnd = new Regex(@"</head>", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
// Write the modified file contents
File.WriteAllText(indexPath, contents);
_logger.LogInformation("Skip button added successfully.");
} }
} }

View File

@ -1,9 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org> // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
using IntroSkipper.Manager;
using IntroSkipper.Providers;
using IntroSkipper.Services;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -19,9 +16,8 @@ namespace IntroSkipper
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{ {
serviceCollection.AddHostedService<AutoSkip>(); serviceCollection.AddHostedService<AutoSkip>();
serviceCollection.AddHostedService<AutoSkipCredits>();
serviceCollection.AddHostedService<Entrypoint>(); serviceCollection.AddHostedService<Entrypoint>();
serviceCollection.AddSingleton<IMediaSegmentProvider, SegmentProvider>();
serviceCollection.AddSingleton<MediaSegmentUpdateManager>();
} }
} }
} }

View File

@ -1,90 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Data;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Model;
using MediaBrowser.Model.MediaSegments;
namespace IntroSkipper.Providers
{
/// <summary>
/// Introskipper media segment provider.
/// </summary>
public class SegmentProvider : IMediaSegmentProvider
{
/// <inheritdoc/>
public string Name => Plugin.Instance!.Name;
/// <inheritdoc/>
public Task<IReadOnlyList<MediaSegmentDto>> GetMediaSegments(MediaSegmentGenerationRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(Plugin.Instance);
var segments = new List<MediaSegmentDto>();
var remainingTicks = Plugin.Instance.Configuration.RemainingSecondsOfIntro * TimeSpan.TicksPerSecond;
var itemSegments = Plugin.Instance.GetTimestamps(request.ItemId);
var runTimeTicks = Plugin.Instance.GetItem(request.ItemId)?.RunTimeTicks ?? 0;
// Define mappings between AnalysisMode and MediaSegmentType
var segmentMappings = new List<(AnalysisMode Mode, MediaSegmentType Type)>
{
(AnalysisMode.Introduction, MediaSegmentType.Intro),
(AnalysisMode.Recap, MediaSegmentType.Recap),
(AnalysisMode.Preview, MediaSegmentType.Preview),
(AnalysisMode.Credits, MediaSegmentType.Outro)
};
foreach (var (mode, type) in segmentMappings)
{
if (itemSegments.TryGetValue(mode, out var segment) && segment.Valid)
{
long startTicks = (long)(segment.Start * TimeSpan.TicksPerSecond);
long endTicks = CalculateEndTicks(mode, segment, runTimeTicks, remainingTicks);
segments.Add(new MediaSegmentDto
{
StartTicks = startTicks,
EndTicks = endTicks,
ItemId = request.ItemId,
Type = type
});
}
}
return Task.FromResult<IReadOnlyList<MediaSegmentDto>>(segments);
}
/// <summary>
/// Calculates the end ticks based on the segment type and runtime.
/// </summary>
private static long CalculateEndTicks(AnalysisMode mode, Segment segment, long runTimeTicks, long remainingTicks)
{
long endTicks = (long)(segment.End * TimeSpan.TicksPerSecond);
if (mode is AnalysisMode.Preview or AnalysisMode.Credits)
{
if (runTimeTicks > 0 && runTimeTicks < endTicks + TimeSpan.TicksPerSecond)
{
return Math.Max(runTimeTicks, endTicks);
}
return endTicks - remainingTicks;
}
return endTicks - remainingTicks;
}
/// <inheritdoc/>
public ValueTask<bool> Supports(BaseItem item) => ValueTask.FromResult(item is Episode or Movie);
}
}

View File

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

View File

@ -7,7 +7,6 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IntroSkipper.Manager;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -69,7 +68,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 async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{ {
if (_libraryManager is null) if (_libraryManager is null)
{ {
@ -82,17 +81,24 @@ 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 = queue.Values var validEpisodeIds = new HashSet<Guid>(queue.Values.SelectMany(episodes => episodes.Select(e => e.EpisodeId)));
.SelectMany(episodes => episodes.Select(e => e.EpisodeId))
.ToHashSet();
await Plugin.Instance!.CleanTimestamps(validEpisodeIds).ConfigureAwait(false); Plugin.Instance!.CleanTimestamps(validEpisodeIds);
// Identify invalid episode IDs // Identify invalid episode IDs
var invalidEpisodeIds = Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath) var invalidEpisodeIds = Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath)
.Select(filePath => Path.GetFileNameWithoutExtension(filePath).Split('-')[0]) .Select(filePath =>
.Where(episodeIdStr => Guid.TryParse(episodeIdStr, out var episodeId) && !validEpisodeIds.Contains(episodeId)) {
.Select(Guid.Parse) var fileName = Path.GetFileNameWithoutExtension(filePath);
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
@ -102,12 +108,31 @@ public class CleanCacheTask : IScheduledTask
FFmpegWrapper.DeleteEpisodeCache(episodeId); FFmpegWrapper.DeleteEpisodeCache(episodeId);
} }
// Clean up Season information by removing items that are no longer exist. // Clean up ignore list by removing items that are no longer exist..
await Plugin.Instance!.CleanSeasonInfoAsync(queue.Keys).ConfigureAwait(false); var removedItems = false;
foreach (var ignoredItem in Plugin.Instance.IgnoreList.Values.ToList())
{
if (!queue.ContainsKey(ignoredItem.SeasonId))
{
removedItems = true;
Plugin.Instance.IgnoreList.TryRemove(ignoredItem.SeasonId, out _);
}
}
Plugin.Instance!.AnalyzeAgain = true; // Save ignore list if at least one item was removed.
if (removedItems)
{
try
{
Plugin.Instance!.SaveIgnoreList();
}
catch (Exception e)
{
_logger.LogError("Failed to save ignore list: {Error}", e.Message);
}
}
progress.Report(100); return Task.CompletedTask;
} }
/// <summary> /// <summary>

View File

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

View File

@ -5,8 +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.Manager; using IntroSkipper.Data;
using IntroSkipper.Services;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -14,33 +13,36 @@ using Microsoft.Extensions.Logging;
namespace IntroSkipper.ScheduledTasks; namespace IntroSkipper.ScheduledTasks;
/// <summary> /// <summary>
/// Analyze all television episodes for media segments. /// Analyze all television episodes for introduction sequences.
/// </summary> /// </summary>
/// <remarks> public class DetectIntrosCreditsTask : IScheduledTask
/// Initializes a new instance of the <see cref="DetectSegmentsTask"/> class.
/// </remarks>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
/// <param name="mediaSegmentUpdateManager">MediaSegment Update Manager.</param>
public class DetectSegmentsTask(
ILogger<DetectSegmentsTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager,
MediaSegmentUpdateManager mediaSegmentUpdateManager) : IScheduledTask
{ {
private readonly ILogger<DetectSegmentsTask> _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;
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager; /// <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> /// <summary>
/// Gets the task name. /// Gets the task name.
/// </summary> /// </summary>
public string Name => "Detect and Analyze Media Segments"; public string Name => "Detect Intros and Credits";
/// <summary> /// <summary>
/// Gets the task category. /// Gets the task category.
@ -55,7 +57,7 @@ public class DetectSegmentsTask(
/// <summary> /// <summary>
/// Gets the task key. /// Gets the task key.
/// </summary> /// </summary>
public string Key => "IntroSkipperDetectSegmentsTask"; public string Key => "CPBIntroSkipperDetectIntrosCredits";
/// <summary> /// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time. /// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
@ -63,7 +65,7 @@ public class DetectSegmentsTask(
/// <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 async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{ {
if (_libraryManager is null) if (_libraryManager is null)
{ {
@ -73,21 +75,25 @@ public class DetectSegmentsTask(
// abort automatic analyzer if running // abort automatic analyzer if running
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling) if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
{ {
_logger.LogInformation("Automatic Task is {TaskState} and will be canceled.", Entrypoint.AutomaticTaskState); _logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
await Entrypoint.CancelAutomaticTaskAsync(cancellationToken).ConfigureAwait(false); Entrypoint.CancelAutomaticTask(cancellationToken);
} }
using (await ScheduledTaskSemaphore.AcquireAsync(cancellationToken).ConfigureAwait(false)) using (ScheduledTaskSemaphore.Acquire(cancellationToken))
{ {
_logger.LogInformation("Scheduled Task is starting"); _logger.LogInformation("Scheduled Task is starting");
var baseIntroAnalyzer = new BaseItemAnalyzerTask( var modes = new List<AnalysisMode> { AnalysisMode.Introduction, AnalysisMode.Credits };
_loggerFactory.CreateLogger<DetectSegmentsTask>(),
_loggerFactory,
_libraryManager,
_mediaSegmentUpdateManager);
await baseIntroAnalyzer.AnalyzeItemsAsync(progress, cancellationToken).ConfigureAwait(false); var baseIntroAnalyzer = new BaseItemAnalyzerTask(
modes,
_loggerFactory.CreateLogger<DetectIntrosCreditsTask>(),
_loggerFactory,
_libraryManager);
baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
return Task.CompletedTask;
} }
} }

View File

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

View File

@ -3,7 +3,6 @@
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
namespace IntroSkipper.ScheduledTasks; namespace IntroSkipper.ScheduledTasks;
@ -15,9 +14,9 @@ internal sealed class ScheduledTaskSemaphore : IDisposable
{ {
} }
public static async Task<IDisposable> AcquireAsync(CancellationToken cancellationToken) public static IDisposable Acquire(CancellationToken cancellationToken)
{ {
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); _semaphore.Wait(cancellationToken);
return new ScheduledTaskSemaphore(); return new ScheduledTaskSemaphore();
} }

View File

@ -2,15 +2,13 @@
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Concurrent;
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 System.Threading.Tasks;
using System.Timers; using System.Timers;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Controllers; using MediaBrowser.Common.Extensions;
using IntroSkipper.Data;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
@ -18,193 +16,218 @@ using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Session; using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace IntroSkipper.Services namespace 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> private readonly object _sentSeekCommandLock = new();
/// Automatically skip past introduction sequences.
/// Commands clients to seek to the end of the intro as soon as they start playing it. private ILogger<AutoSkip> _logger = logger;
/// </summary> private IUserDataManager _userDataManager = userDataManager;
/// <remarks> private ISessionManager _sessionManager = sessionManager;
/// Initializes a new instance of the <see cref="AutoSkip"/> class. private Timer _playbackTimer = new(1000);
/// </remarks> private Dictionary<string, bool> _sentSeekCommand = [];
/// <param name="userDataManager">User data manager.</param> private HashSet<string> _clientList = [];
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param> private void AutoSkipChanged(object? sender, BasePluginConfiguration e)
public sealed class AutoSkip(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkip> logger) : IHostedService, IDisposable
{ {
private readonly IUserDataManager _userDataManager = userDataManager; var configuration = (PluginConfiguration)e;
private readonly ISessionManager _sessionManager = sessionManager; _clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
private readonly ILogger<AutoSkip> _logger = logger; var newState = configuration.AutoSkip || (configuration.SkipButtonVisible && _clientList.Count > 0);
private readonly System.Timers.Timer _playbackTimer = new(1000); _logger.LogDebug("Setting playback timer enabled to {NewState}", newState);
private readonly ConcurrentDictionary<string, List<Intro>> _sentSeekCommand = []; _playbackTimer.Enabled = newState;
private PluginConfiguration _config = new(); }
private HashSet<string> _clientList = [];
private HashSet<AnalysisMode> _segmentTypes = [];
private bool _autoSkipEnabled;
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)
{ {
_config = (PluginConfiguration)e; return;
_clientList = [.. _config.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
_segmentTypes = [.. _config.TypeList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(Enum.Parse<AnalysisMode>)];
_autoSkipEnabled = (_config.AutoSkip || _clientList.Count > 0) && _segmentTypes.Count > 0;
_logger.LogDebug("Setting playback timer enabled to {AutoSkipEnabled}", _autoSkipEnabled);
_playbackTimer.Enabled = _autoSkipEnabled;
} }
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) // Lookup the session for this item.
SessionInfo? session = null;
try
{ {
// Ignore all events except playback start & end foreach (var needle in _sessionManager.Sessions)
if (e.SaveReason is not (UserDataSaveReason.PlaybackStart or UserDataSaveReason.PlaybackFinished) || !_autoSkipEnabled)
{ {
return; if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
}
var itemId = e.Item.Id;
var session = _sessionManager.Sessions
.FirstOrDefault(s => s.UserId == e.UserId && s.NowPlayingItem?.Id == itemId);
if (session is null)
{
// Clean up orphaned sessions
if (!_sessionManager.Sessions
.Where(s => s.UserId == e.UserId && s.NowPlayingItem is null)
.Any(s => _sentSeekCommand.TryRemove(s.DeviceId, out _)))
{ {
_logger.LogInformation("Unable to find active session for item {ItemId}", itemId); session = needle;
break;
} }
return;
} }
// Reset the seek command state for this device. 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; var device = session.DeviceId;
_logger.LogDebug("Getting intros for session {Session}", device);
bool firstEpisode = _config.SkipFirstEpisode && e.Item.IndexNumber.GetValueOrDefault(-1) == 1; _logger.LogDebug("Resetting seek command state for session {Session}", device);
var intros = SkipIntroController.GetIntros(itemId) _sentSeekCommand[device] = newState;
.Where(i => _segmentTypes.Contains(i.Key) && (!firstEpisode || i.Key != AnalysisMode.Introduction))
.Select(i => i.Value)
.ToList();
_sentSeekCommand.AddOrUpdate(device, intros, (_, _) => intros);
}
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
foreach (var session in _sessionManager.Sessions.Where(s => _config.AutoSkip || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase)))
{
var deviceId = session.DeviceId;
// Don't send the seek command more than once in the same session.
if (!_sentSeekCommand.TryGetValue(deviceId, out var intros))
{
continue;
}
var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
var currentIntro = intros.FirstOrDefault(i =>
position >= Math.Max(1, i.IntroStart + _config.SecondsOfIntroStartToPlay) &&
position < i.IntroEnd - 3.0); // 3 seconds before the end of the intro
if (currentIntro is null)
{
continue;
}
var introEnd = currentIntro.IntroEnd;
intros.Remove(currentIntro);
// Check if adjacent segment is within the maximum skip range.
var maxTimeSkip = _config.MaximumTimeSkip + _config.RemainingSecondsOfIntro;
var nextIntro = intros.FirstOrDefault(i => introEnd + maxTimeSkip >= i.IntroStart &&
introEnd < i.IntroEnd);
if (nextIntro is not null)
{
introEnd = nextIntro.IntroEnd;
intros.Remove(nextIntro);
}
_logger.LogDebug("Found segment for session {Session}, removing from list, {Intros} segments remaining", deviceId, intros.Count);
_logger.LogTrace(
"Playback position is {Position}",
position);
// Notify the user that an introduction is being skipped for them.
var notificationText = _config.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)introEnd * TimeSpan.TicksPerSecond,
},
CancellationToken.None);
// Flag that we've sent the seek command so that it's not sent repeatedly
_logger.LogTrace("Setting seek command state for session {Session}", deviceId);
}
}
/// <summary>
/// Dispose resources.
/// </summary>
public void Dispose()
{
_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;
Plugin.Instance!.ConfigurationChanged -= AutoSkipChanged;
_playbackTimer.Stop();
return Task.CompletedTask;
} }
} }
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkip || (Plugin.Instance!.Configuration.SkipButtonVisible && _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

@ -0,0 +1,233 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using IntroSkipper.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace IntroSkipper;
/// <summary>
/// Automatically skip past credit sequences.
/// Commands clients to seek to the end of the credits as soon as they start playing it.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="AutoSkipCredits"/> class.
/// </remarks>
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public class AutoSkipCredits(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkipCredits> logger) : IHostedService, IDisposable
{
private readonly object _sentSeekCommandLock = new();
private ILogger<AutoSkipCredits> _logger = logger;
private IUserDataManager _userDataManager = userDataManager;
private ISessionManager _sessionManager = sessionManager;
private Timer _playbackTimer = new(1000);
private Dictionary<string, bool> _sentSeekCommand = [];
private HashSet<string> _clientList = [];
private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e)
{
var configuration = (PluginConfiguration)e;
_clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
var newState = configuration.AutoSkipCredits || (configuration.SkipButtonVisible && _clientList.Count > 0);
_logger.LogDebug("Setting playback timer enabled to {NewState}", newState);
_playbackTimer.Enabled = newState;
}
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
{
var itemId = e.Item.Id;
var newState = false;
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
// Ignore all events except playback start & end
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
{
return;
}
// Lookup the session for this item.
SessionInfo? session = null;
try
{
foreach (var needle in _sessionManager.Sessions)
{
if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
{
session = needle;
break;
}
}
if (session == null)
{
_logger.LogInformation("Unable to find session for {Item}", itemId);
return;
}
}
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
{
return;
}
// If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session.
if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
{
newState = true;
}
// Reset the seek command state for this device.
lock (_sentSeekCommandLock)
{
var device = session.DeviceId;
_logger.LogDebug("Resetting seek command state for session {Session}", device);
_sentSeekCommand[device] = newState;
}
}
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkipCredits || (Plugin.Instance!.Configuration.SkipButtonVisible && _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

@ -6,9 +6,8 @@ using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Manager; using IntroSkipper.Data;
using IntroSkipper.ScheduledTasks; using IntroSkipper.ScheduledTasks;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
@ -17,228 +16,314 @@ using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace IntroSkipper.Services namespace IntroSkipper;
/// <summary>
/// Server entrypoint.
/// </summary>
public sealed class Entrypoint : IHostedService, IDisposable
{ {
private readonly ITaskManager _taskManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<Entrypoint> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly HashSet<Guid> _seasonsToAnalyze = [];
private readonly Timer _queueTimer;
private static readonly ManualResetEventSlim _autoTaskCompletEvent = new(false);
private PluginConfiguration _config;
private bool _analyzeAgain;
private static CancellationTokenSource? _cancellationTokenSource;
/// <summary> /// <summary>
/// Server entrypoint. /// Initializes a new instance of the <see cref="Entrypoint"/> class.
/// </summary> /// </summary>
public sealed class Entrypoint : IHostedService, IDisposable /// <param name="libraryManager">Library manager.</param>
/// <param name="taskManager">Task manager.</param>
/// <param name="logger">Logger.</param>
/// <param name="loggerFactory">Logger factory.</param>
public Entrypoint(
ILibraryManager libraryManager,
ITaskManager taskManager,
ILogger<Entrypoint> logger,
ILoggerFactory loggerFactory)
{ {
private readonly ITaskManager _taskManager; _libraryManager = libraryManager;
private readonly ILibraryManager _libraryManager; _taskManager = taskManager;
private readonly ILogger<Entrypoint> _logger; _logger = logger;
private readonly ILoggerFactory _loggerFactory; _loggerFactory = loggerFactory;
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager;
private readonly HashSet<Guid> _seasonsToAnalyze = [];
private readonly Timer _queueTimer;
private static readonly SemaphoreSlim _analysisSemaphore = new(1, 1);
private PluginConfiguration _config;
private bool _analyzeAgain;
private static CancellationTokenSource? _cancellationTokenSource;
/// <summary> _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// Initializes a new instance of the <see cref="Entrypoint"/> class. _queueTimer = new Timer(
/// </summary> OnTimerCallback,
/// <param name="libraryManager">Library manager.</param> null,
/// <param name="taskManager">Task manager.</param> Timeout.InfiniteTimeSpan,
/// <param name="logger">Logger.</param> Timeout.InfiniteTimeSpan);
/// <param name="loggerFactory">Logger factory.</param> }
/// <param name="mediaSegmentUpdateManager">MediaSegment Update Manager.</param>
public Entrypoint( /// <summary>
ILibraryManager libraryManager, /// Gets State of the automatic task.
ITaskManager taskManager, /// </summary>
ILogger<Entrypoint> logger, public static TaskState AutomaticTaskState
ILoggerFactory loggerFactory, {
MediaSegmentUpdateManager mediaSegmentUpdateManager) get
{ {
_libraryManager = libraryManager; if (_cancellationTokenSource is not null)
_taskManager = taskManager; {
_logger = logger; return _cancellationTokenSource.IsCancellationRequested
_loggerFactory = loggerFactory; ? TaskState.Cancelling
_mediaSegmentUpdateManager = mediaSegmentUpdateManager; : TaskState.Running;
}
_config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); return TaskState.Idle;
_queueTimer = new Timer(
OnTimerCallback,
null,
Timeout.InfiniteTimeSpan,
Timeout.InfiniteTimeSpan);
} }
}
/// <summary> /// <inheritdoc />
/// Gets State of the automatic task. public Task StartAsync(CancellationToken cancellationToken)
/// </summary> {
public static TaskState AutomaticTaskState => _cancellationTokenSource switch _libraryManager.ItemAdded += OnItemAdded;
_libraryManager.ItemUpdated += OnItemModified;
_taskManager.TaskCompleted += OnLibraryRefresh;
Plugin.Instance!.ConfigurationChanged += OnSettingsChanged;
FFmpegWrapper.Logger = _logger;
try
{ {
null => TaskState.Idle,
{ IsCancellationRequested: true } => TaskState.Cancelling,
_ => TaskState.Running
};
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_libraryManager.ItemAdded += OnItemChanged;
_libraryManager.ItemUpdated += OnItemChanged;
_taskManager.TaskCompleted += OnLibraryRefresh;
Plugin.Instance!.ConfigurationChanged += OnSettingsChanged;
FFmpegWrapper.Logger = _logger;
// Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible // Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible
_logger.LogInformation("Running startup enqueue"); _logger.LogInformation("Running startup enqueue");
new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager).GetMediaItems(); var queueManager = new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager);
queueManager?.GetMediaItems();
return Task.CompletedTask; }
catch (Exception ex)
{
_logger.LogError("Unable to run startup enqueue: {Exception}", ex);
} }
/// <inheritdoc /> return Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) }
{
_libraryManager.ItemAdded -= OnItemChanged;
_libraryManager.ItemUpdated -= OnItemChanged;
_taskManager.TaskCompleted -= OnLibraryRefresh;
Plugin.Instance!.ConfigurationChanged -= OnSettingsChanged;
_queueTimer.Change(Timeout.Infinite, 0); /// <inheritdoc />
return Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken)
{
_libraryManager.ItemAdded -= OnItemAdded;
_libraryManager.ItemUpdated -= OnItemModified;
_taskManager.TaskCompleted -= OnLibraryRefresh;
// Stop the timer
_queueTimer.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
// Disclose source for inspiration
// Implementation based on the principles of jellyfin-plugin-media-analyzer:
// https://github.com/endrl/jellyfin-plugin-media-analyzer
/// <summary>
/// Library item was added.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
private void OnItemAdded(object? sender, ItemChangeEventArgs itemChangeEventArgs)
{
// Don't do anything if auto detection is disabled
if (!_config.AutoDetectIntros && !_config.AutoDetectCredits)
{
return;
} }
/// <summary> // Don't do anything if it's not a supported media type
/// Library item was added. if (itemChangeEventArgs.Item is not Episode episode)
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
private void OnItemChanged(object? sender, ItemChangeEventArgs itemChangeEventArgs)
{ {
if (_config.AutoDetectIntros && return;
itemChangeEventArgs.Item is { LocationType: not LocationType.Virtual } item) }
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
{
return;
}
_seasonsToAnalyze.Add(episode.SeasonId);
StartTimer();
}
/// <summary>
/// Library item was modified.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
private void OnItemModified(object? sender, ItemChangeEventArgs itemChangeEventArgs)
{
// Don't do anything if auto detection is disabled
if (!_config.AutoDetectIntros && !_config.AutoDetectCredits)
{
return;
}
// Don't do anything if it's not a supported media type
if (itemChangeEventArgs.Item is not Episode episode)
{
return;
}
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
{
return;
}
_seasonsToAnalyze.Add(episode.SeasonId);
StartTimer();
}
/// <summary>
/// TaskManager task ended.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs)
{
// Don't do anything if auto detection is disabled
if (!_config.AutoDetectIntros && !_config.AutoDetectCredits)
{
return;
}
var result = eventArgs.Result;
if (result.Key != "RefreshLibrary")
{
return;
}
if (result.Status != TaskCompletionStatus.Completed)
{
return;
}
// Unless user initiated, this is likely an overlap
if (AutomaticTaskState == TaskState.Running)
{
return;
}
StartTimer();
}
private void OnSettingsChanged(object? sender, BasePluginConfiguration e) => _config = (PluginConfiguration)e;
/// <summary>
/// Start timer to debounce analyzing.
/// </summary>
private void StartTimer()
{
if (AutomaticTaskState == TaskState.Running)
{
_analyzeAgain = true;
}
else if (AutomaticTaskState == TaskState.Idle)
{
_logger.LogDebug("Media Library changed, analyzis will start soon!");
_queueTimer.Change(TimeSpan.FromMilliseconds(20000), Timeout.InfiniteTimeSpan);
}
}
/// <summary>
/// Wait for timer callback to be completed.
/// </summary>
private void OnTimerCallback(object? state)
{
try
{
PerformAnalysis();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in PerformAnalysis");
}
// Clean up
_cancellationTokenSource = null;
_autoTaskCompletEvent.Set();
}
/// <summary>
/// Wait for timer to be completed.
/// </summary>
private void PerformAnalysis()
{
_logger.LogInformation("Initiate automatic analysis task.");
_autoTaskCompletEvent.Reset();
using (_cancellationTokenSource = new CancellationTokenSource())
using (ScheduledTaskSemaphore.Acquire(_cancellationTokenSource.Token))
{
var seasonIds = new HashSet<Guid>(_seasonsToAnalyze);
_seasonsToAnalyze.Clear();
_analyzeAgain = false;
var progress = new Progress<double>();
var modes = new List<AnalysisMode>();
var tasklogger = _loggerFactory.CreateLogger("DefaultLogger");
if (_config.AutoDetectIntros)
{ {
Guid? id = item is Episode episode ? episode.SeasonId : (item is Movie movie ? movie.Id : null); modes.Add(AnalysisMode.Introduction);
tasklogger = _loggerFactory.CreateLogger<DetectIntrosTask>();
if (id.HasValue)
{
_seasonsToAnalyze.Add(id.Value);
StartTimer();
}
} }
}
/// <summary> if (_config.AutoDetectCredits)
/// TaskManager task ended.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs)
{
if (_config.AutoDetectIntros &&
eventArgs.Result is { Key: "RefreshLibrary", Status: TaskCompletionStatus.Completed } &&
AutomaticTaskState != TaskState.Running)
{ {
modes.Add(AnalysisMode.Credits);
tasklogger = modes.Count == 2
? _loggerFactory.CreateLogger<DetectIntrosCreditsTask>()
: _loggerFactory.CreateLogger<DetectCreditsTask>();
}
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
modes,
tasklogger,
_loggerFactory,
_libraryManager);
baseCreditAnalyzer.AnalyzeItems(progress, _cancellationTokenSource.Token, seasonIds);
// New item detected, start timer again
if (_analyzeAgain && !_cancellationTokenSource.IsCancellationRequested)
{
_logger.LogInformation("Analyzing ended, but we need to analyze again!");
StartTimer(); StartTimer();
} }
} }
}
private void OnSettingsChanged(object? sender, BasePluginConfiguration e) /// <summary>
{ /// Method to cancel the automatic task.
_config = (PluginConfiguration)e; /// </summary>
Plugin.Instance!.AnalyzeAgain = true; /// <param name="cancellationToken">Cancellation token.</param>
} public static void CancelAutomaticTask(CancellationToken cancellationToken)
{
/// <summary> if (_cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested)
/// Start timer to debounce analyzing.
/// </summary>
private void StartTimer()
{
if (AutomaticTaskState == TaskState.Running)
{
_analyzeAgain = true;
}
else if (AutomaticTaskState == TaskState.Idle)
{
_logger.LogDebug("Media Library changed, analyzis will start soon!");
_queueTimer.Change(TimeSpan.FromSeconds(60), Timeout.InfiniteTimeSpan);
}
}
private void OnTimerCallback(object? state) =>
_ = RunAnalysisAsync();
private async Task RunAnalysisAsync()
{ {
try try
{ {
await PerformAnalysisAsync().ConfigureAwait(false); _cancellationTokenSource.Cancel();
} }
catch (OperationCanceledException) catch (ObjectDisposedException)
{ {
_logger.LogInformation("Automatic Analysis task cancelled"); _cancellationTokenSource = null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in RunAnalysisAsync");
}
_cancellationTokenSource = null;
}
private async Task PerformAnalysisAsync()
{
await _analysisSemaphore.WaitAsync().ConfigureAwait(false);
try
{
using (_cancellationTokenSource = new CancellationTokenSource())
using (await ScheduledTaskSemaphore.AcquireAsync(_cancellationTokenSource.Token).ConfigureAwait(false))
{
_logger.LogInformation("Initiating automatic analysis task");
var seasonIds = new HashSet<Guid>(_seasonsToAnalyze);
_seasonsToAnalyze.Clear();
_analyzeAgain = false;
var analyzer = new BaseItemAnalyzerTask(_loggerFactory.CreateLogger<Entrypoint>(), _loggerFactory, _libraryManager, _mediaSegmentUpdateManager);
await analyzer.AnalyzeItemsAsync(new Progress<double>(), _cancellationTokenSource.Token, seasonIds).ConfigureAwait(false);
if (_analyzeAgain && !_cancellationTokenSource.IsCancellationRequested)
{
_logger.LogInformation("Analyzing ended, but we need to analyze again!");
_queueTimer.Change(TimeSpan.FromSeconds(60), Timeout.InfiniteTimeSpan);
}
}
}
finally
{
_analysisSemaphore.Release();
} }
} }
/// <summary> _autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal
/// Method to cancel the automatic task. }
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public static async Task CancelAutomaticTaskAsync(CancellationToken cancellationToken)
{
if (_cancellationTokenSource is { IsCancellationRequested: false })
{
try
{
await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
}
catch (ObjectDisposedException)
{
_cancellationTokenSource = null;
}
}
await _analysisSemaphore.WaitAsync(TimeSpan.FromSeconds(60), cancellationToken).ConfigureAwait(false); /// <inheritdoc/>
} public void Dispose()
{
/// <inheritdoc/> _queueTimer.Dispose();
public void Dispose() _cancellationTokenSource?.Dispose();
{ _autoTaskCompletEvent.Dispose();
_queueTimer.Dispose();
_cancellationTokenSource?.Dispose();
_analysisSemaphore.Dispose();
}
} }
} }

View File

@ -9,9 +9,6 @@
</p> </p>
[![CodeQL](https://github.com/intro-skipper/intro-skipper/actions/workflows/codeql.yml/badge.svg)](https://github.com/intro-skipper/intro-skipper/actions/workflows/codeql.yml) [![CodeQL](https://github.com/intro-skipper/intro-skipper/actions/workflows/codeql.yml/badge.svg)](https://github.com/intro-skipper/intro-skipper/actions/workflows/codeql.yml)
<a href="https://github.com/intro-skipper/intro-skipper/releases">
<img alt="Total GitHub Downloads" src="https://img.shields.io/github/downloads/intro-skipper/intro-skipper/total?label=github%20downloads"/>
</a>
</div> </div>
## Manifest URL (All Jellyfin Versions) ## Manifest URL (All Jellyfin Versions)
@ -22,11 +19,11 @@ https://manifest.intro-skipper.org/manifest.json
## System requirements ## System requirements
* Jellyfin 10.10.3 (or newer) * Jellyfin 10.9.11 (or newer)
* Jellyfin's [fork](https://github.com/jellyfin/jellyfin-ffmpeg) of `ffmpeg` must be installed, version `7.0.2-5` or newer * Jellyfin's [fork](https://github.com/jellyfin/jellyfin-ffmpeg) of `ffmpeg` must be installed, version `6.0.1-5` or newer
* `jellyfin/jellyfin` 10.10.z container: preinstalled * `jellyfin/jellyfin` 10.9.z container: preinstalled
* `linuxserver/jellyfin` 10.10.z container: preinstalled * `linuxserver/jellyfin` 10.9.z container: preinstalled
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg7` package * Debian Linux based native installs: provided by the `jellyfin-ffmpeg6` package
* MacOS native installs: build ffmpeg with chromaprint support ([instructions](https://github.com/intro-skipper/intro-skipper/wiki/Custom-FFMPEG-(MacOS))) * MacOS native installs: build ffmpeg with chromaprint support ([instructions](https://github.com/intro-skipper/intro-skipper/wiki/Custom-FFMPEG-(MacOS)))
## Limitations ## Limitations
@ -38,17 +35,10 @@ https://manifest.intro-skipper.org/manifest.json
## [Detection types](https://github.com/intro-skipper/intro-skipper/wiki#detection-types) ## [Detection types](https://github.com/intro-skipper/intro-skipper/wiki#detection-types)
## [Installation](https://github.com/intro-skipper/intro-skipper/wiki/Installation) ## [Installation](https://github.com/intro-skipper/intro-skipper/wiki/Installation)
- #### [Install the plugin](https://github.com/intro-skipper/intro-skipper/wiki/Installation#step-1-install-the-plugin)
- #### [Verify the plugin](https://github.com/intro-skipper/intro-skipper/wiki/Installation#step-2-verify-the-plugin)
- #### [Custom FFMPEG (MacOS)](https://github.com/intro-skipper/intro-skipper/wiki/Custom-FFMPEG-(MacOS))
## [Jellyfin Skip Options](https://github.com/intro-skipper/intro-skipper/wiki/Jellyfin-Skip-Options) ## [Jellyfin Skip Options](https://github.com/intro-skipper/intro-skipper/wiki/Jellyfin-Skip-Options)
## [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting) ## [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting)
- #### [Scheduled tasks fail instantly](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#scheduled-tasks-fail-instantly)
- #### [Plugin settings not saved](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#plugin-settings-not-saved)
- #### [Skip button is not visible](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible)
- #### [Auto skip is not working](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#auto-skip-is-not-working)
## [API Documentation](https://github.com/intro-skipper/intro-skipper/blob/master/docs/api.md) ## [API Documentation](https://github.com/intro-skipper/intro-skipper/blob/master/docs/api.md)

View File

@ -1 +1 @@
10.10 10.9

16
docs/edl.md Normal file
View File

@ -0,0 +1,16 @@
# EDL support
The timestamps of discovered introductions can be written to [EDL](https://kodi.wiki/view/Edit_decision_list) files alongside your media files. EDL files are saved when:
* Scanning an episode for the first time, or
* If requested with the regenerate checkbox
## Configuration
Jellyfin must have read/write access to your TV show libraries in order to make use of this feature.
## Usage
To have the plugin create EDL files:
1. Change the EDL action from the default of None to any of the other supported EDL actions
2. Check the "Regenerate EDL files during next analysis" checkbox
1. If this option is not selected, only seasons with a newly analyzed episode will have EDL files created.

View File

@ -9,12 +9,20 @@
"imageUrl": "https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/images/logo.png", "imageUrl": "https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/images/logo.png",
"versions": [ "versions": [
{ {
"version": "1.10.10.11", "version": "1.10.9.2",
"changelog": "- See the full changelog at [GitHub](https://github.com/intro-skipper/intro-skipper/releases/tag/10.10/v1.10.10.11)\n", "changelog": "- See the full changelog at [GitHub](https://github.com/intro-skipper/intro-skipper/releases/tag/10.9/v1.10.9.2)\n",
"targetAbi": "10.10.3.0", "targetAbi": "10.9.11.0",
"sourceUrl": "https://github.com/intro-skipper/intro-skipper/releases/download/10.10/v1.10.10.11/intro-skipper-v1.10.10.11.zip", "sourceUrl": "https://github.com/intro-skipper/intro-skipper/releases/download/10.9/v1.10.9.2/intro-skipper-v1.10.9.2.zip",
"checksum": "30a71fd3996e0fbe9076371539b1ca56", "checksum": "71a819c0f5657d14e7181e409455b5d8",
"timestamp": "2024-11-25T17:07:33Z" "timestamp": "2024-11-05T14:36:23Z"
},
{
"version": "1.10.9.1",
"changelog": "- See the full changelog at [GitHub](https://github.com/intro-skipper/intro-skipper/releases/tag/10.9/v1.10.9.1)\n",
"targetAbi": "10.9.11.0",
"sourceUrl": "https://github.com/intro-skipper/intro-skipper/releases/download/10.9/v1.10.9.1/intro-skipper-v1.10.9.1.zip",
"checksum": "e1c2b2e48784ec9138de17048930552b",
"timestamp": "2024-10-26T18:09:56Z"
} }
] ]
} }

226
webui.patch Normal file
View File

@ -0,0 +1,226 @@
diff --git a/src/controllers/playback/video/index.html b/src/controllers/playback/video/index.html
index a460ee8f6a3..d7b344d4b1b 100644
--- a/src/controllers/playback/video/index.html
+++ b/src/controllers/playback/video/index.html
@@ -6,6 +6,12 @@
</div>
</div>
<div class="upNextContainer hide"></div>
+ <div class="skipIntro hide">
+ <button is="emby-button" type="button" class="btnSkipIntro injected">
+ <span id="btnSkipSegmentText"></span>
+ <span class="material-icons skip_next"></span>
+ </button>
+ </div>
<div class="videoOsdBottom videoOsdBottom-maincontrols">
<div class="osdControls">
<div class="osdTextContainer osdMainTextContainer">
diff --git a/src/controllers/playback/video/index.js b/src/controllers/playback/video/index.js
index 2adad5708c3..5b81eebc7f1 100644
--- a/src/controllers/playback/video/index.js
+++ b/src/controllers/playback/video/index.js
@@ -365,7 +365,7 @@ export default function (view) {
toggleSubtitleSync('hide');
// Firefox does not blur by itself
- if (document.activeElement) {
+ if (document.activeElement && !skipButton.contains(document.activeElement)) {
document.activeElement.blur();
}
}
@@ -517,9 +517,95 @@ export default function (view) {
updatePlaylist();
enableStopOnBack(true);
updatePlaybackRate(player);
+ getIntroTimestamps(state.NowPlayingItem);
}
}
+ function secureFetch(url) {
+ const apiClient = ServerConnections.currentApiClient();
+ const address = apiClient.serverAddress();
+ const reqInit = {
+ headers: {
+ "Authorization": `MediaBrowser Token=${apiClient.accessToken()}`
+ }
+ };
+ return fetch(`${address}${url}`, reqInit).then(r => {
+ return r.ok ? r.json() : null;
+ });
+ }
+
+ function getIntroTimestamps(item) {
+ secureFetch(`/Episode/${item.Id}/IntroSkipperSegments`).then(segments => {
+ skipSegments = segments;
+ hasCreditsSegment = Object.keys(segments).some(key => key === "Credits");
+ }).catch(err => {
+ skipSegments = {};
+ hasCreditsSegment = false; });
+ secureFetch(`/Intros/UserInterfaceConfiguration`).then(config => {
+ skipButton.dataset.Introduction = config.SkipButtonIntroText;
+ skipButton.dataset.Credits = config.SkipButtonEndCreditsText;
+ }).catch(err => {
+ skipButton.dataset.Introduction = 'Skip Intro';
+ skipButton.dataset.Credits = 'Next'; });
+ }
+
+ function getCurrentSegment(position) {
+ for (const [key, segment] of Object.entries(skipSegments)) {
+ if ((position > segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt - 1) ||
+ (currentVisibleMenu === 'osd' && position > segment.IntroStart && position < segment.IntroEnd - 1)) {
+ segment.SegmentType = key;
+ return segment;
+ }
+ }
+ return { SegmentType: "None" };
+ }
+
+ function videoPositionChanged(currentTime) {
+ const embyButton = skipButton.querySelector(".emby-button");
+ const segmentType = getCurrentSegment(currentTime / TICKS_PER_SECOND).SegmentType;
+ if (segmentType === "None") {
+ if (!skipButton.classList.contains('show')) return;
+ skipButton.classList.remove('show');
+ embyButton.addEventListener("transitionend", () => {
+ skipButton.classList.add("hide");
+ if (!currentVisibleMenu) {
+ embyButton.blur();
+ } else {
+ _focus(osdBottomElement.querySelector('.btnPause'));
+ }
+ }, { once: true });
+ return;
+ }
+ skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset[segmentType];
+ if (!skipButton.classList.contains("hide")) {
+ if (!currentVisibleMenu && !embyButton.contains(document.activeElement)) _focus(embyButton);
+ return;
+ }
+ requestAnimationFrame(() => {
+ skipButton.classList.remove("hide");
+ requestAnimationFrame(() => {
+ skipButton.classList.add('show');
+ _focus(embyButton);
+ });
+ });
+ }
+
+ function doSkip() {
+ const segment = getCurrentSegment(playbackManager.currentTime(currentPlayer) / 1000);
+ if (segment.SegmentType === "None") {
+ console.warn("[intro skipper] doSkip() called without an active segment");
+ return;
+ }
+ playbackManager.seek(segment.IntroEnd * TICKS_PER_SECOND, currentPlayer);
+ }
+
+ function eventHandler(e) {
+ if (e.key !== "Enter") return;
+ e.stopPropagation();
+ e.preventDefault();
+ doSkip();
+ }
+
function onPlayPauseStateChanged() {
if (isEnabled) {
updatePlayPauseState(this.paused());
@@ -637,12 +723,13 @@ export default function (view) {
const item = currentItem;
refreshProgramInfoIfNeeded(player, item);
showComingUpNextIfNeeded(player, item, currentTime, currentRuntimeTicks);
+ videoPositionChanged(currentTime);
}
}
}
function showComingUpNextIfNeeded(player, currentItem, currentTimeTicks, runtimeTicks) {
- if (runtimeTicks && currentTimeTicks && !comingUpNextDisplayed && !currentVisibleMenu && currentItem.Type === 'Episode' && userSettings.enableNextVideoInfoOverlay()) {
+ if (!hasCreditsSegment && runtimeTicks && currentTimeTicks && !comingUpNextDisplayed && !currentVisibleMenu && currentItem.Type === 'Episode' && userSettings.enableNextVideoInfoOverlay()) {
let showAtSecondsLeft = 30;
if (runtimeTicks >= 50 * TICKS_PER_MINUTE) {
showAtSecondsLeft = 40;
@@ -1543,7 +1630,10 @@ export default function (view) {
let programEndDateMs = 0;
let playbackStartTimeTicks = 0;
let subtitleSyncOverlay;
+ let skipSegments = {};
+ let hasCreditsSegment;
let trickplayResolution = null;
+ const skipButton = document.querySelector(".skipIntro");
const nowPlayingVolumeSlider = view.querySelector('.osdVolumeSlider');
const nowPlayingVolumeSliderContainer = view.querySelector('.osdVolumeSliderContainer');
const nowPlayingPositionSlider = view.querySelector('.osdPositionSlider');
@@ -1699,6 +1789,10 @@ export default function (view) {
let lastPointerDown = 0;
/* eslint-disable-next-line compat/compat */
dom.addEventListener(view, window.PointerEvent ? 'pointerdown' : 'click', function (e) {
+ if (dom.parentWithClass(e.target, ['btnSkipIntro'])) {
+ return;
+ }
+
if (dom.parentWithClass(e.target, ['videoOsdBottom', 'upNextContainer'])) {
showOsd();
return;
@@ -1854,6 +1948,8 @@ export default function (view) {
});
view.querySelector('.btnAudio').addEventListener('click', showAudioTrackSelection);
view.querySelector('.btnSubtitles').addEventListener('click', showSubtitleTrackSelection);
+ skipButton.addEventListener('click', doSkip);
+ skipButton.addEventListener("keydown", eventHandler);
// HACK: Remove `emby-button` from the rating button to make it look like the other buttons
view.querySelector('.btnUserRating').classList.remove('emby-button');
@@ -1964,4 +2060,3 @@ export default function (view) {
});
}
}
-
diff --git a/src/styles/videoosd.scss b/src/styles/videoosd.scss
index 2c8c00e2601..336b2bacad3 100644
--- a/src/styles/videoosd.scss
+++ b/src/styles/videoosd.scss
@@ -346,3 +346,44 @@
transform: rotate(-360deg);
}
}
+
+:root {
+ --rounding: 4px;
+ --accent: 0, 164, 220;
+}
+.skipIntro {
+ position: absolute;
+ bottom: 7.5em;
+ right: 5em;
+ background-color: transparent;
+}
+.skipIntro .emby-button {
+ color: #ffffff;
+ font-size: 110%;
+ background: rgba(0, 0, 0, 0.7);
+ border-radius: var(--rounding);
+ box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
+ transition: opacity 0.3s cubic-bezier(0.4,0,0.2,1),
+ transform 0.3s cubic-bezier(0.4,0,0.2,1),
+ background-color 0.2s ease-out,
+ box-shadow 0.2s ease-out;
+ opacity: 0;
+ transform: translateY(50%);
+}
+.skipIntro.show .emby-button {
+ opacity: 1;
+ transform: translateY(0);
+}
+.skipIntro .emby-button:hover {
+ background: rgb(var(--accent));
+ box-shadow: 0 0 8px rgba(var(--accent), 0.6);
+ filter: brightness(1.2);
+}
+.skipIntro .emby-button:focus {
+ background: rgb(var(--accent));
+ box-shadow: 0 0 8px rgba(var(--accent), 0.6);
+}
+.btnSkipSegmentText {
+ letter-spacing: 0.5px;
+ padding: 0 5px 0 5px;
+}