Compare commits
57 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
350c7684e3 | ||
|
6f504132ac | ||
|
d3fb5ff8d7 | ||
|
df28ef8be7 | ||
|
25d6700c1a | ||
|
71c2c69605 | ||
|
390ff41ac3 | ||
|
d9973ed90a | ||
|
36825a898d | ||
|
74fd8f3f75 | ||
|
e1420027c0 | ||
|
89f996100c | ||
|
61739a1afd | ||
|
3e92d0f6f5 | ||
|
01a6d855f9 | ||
|
6bb92eda01 | ||
|
f9611eae78 | ||
|
8f46af69e4 | ||
|
b4fdf14f26 | ||
|
9893aac067 | ||
|
e68eaf8c0e | ||
|
f32408109d | ||
|
97840f2a7d | ||
|
9ff8b19ab5 | ||
|
2653c0a314 | ||
|
80bbc0fa00 | ||
|
e9582d431c | ||
|
40474b2d3b | ||
|
073245a890 | ||
|
bd2b6d4bca | ||
|
45279e0a85 | ||
|
7de97ae7da | ||
|
a9baea5c16 | ||
|
06f69e6602 | ||
|
45d9e4b632 | ||
|
4d7dbf7f0f | ||
|
fe57d4defa | ||
|
3445bbaee4 | ||
|
38bc136088 | ||
|
50ac67113a | ||
|
627ae05def | ||
|
7198dfdfca | ||
|
acb902824d | ||
|
fef3b4d178 | ||
|
8627203748 | ||
|
8d0f17e18b | ||
|
6387d0e6a2 | ||
|
bdc7c9914c | ||
|
e1ad284fab | ||
|
e59bc24965 | ||
|
12d82da4fa | ||
|
7327f3f46e | ||
|
84801c8634 | ||
|
2b87b122c2 | ||
|
4cdef4f228 | ||
|
7264b7b8f0 | ||
|
d1bdf764d1 |
2
.github/ISSUE_TEMPLATE/bug_report_form.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report_form.yml
vendored
@ -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:
|
||||||
|
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
49
IntroSkipper.Tests/TestEdl.cs
Normal file
49
IntroSkipper.Tests/TestEdl.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
119
IntroSkipper/Analyzers/AnalyzerHelper.cs
Normal file
119
IntroSkipper/Analyzers/AnalyzerHelper.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
|
||||||
|
var chapters = Plugin.Instance!.GetChapters(episode.EpisodeId);
|
||||||
|
var lastSuitableChapter = chapters.LastOrDefault(c =>
|
||||||
{
|
{
|
||||||
if (searchStart < _config.MinimumCreditsDuration)
|
var start = TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds;
|
||||||
|
return start >= _minimumCreditsDuration && start <= creditDuration;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lastSuitableChapter is not null)
|
||||||
{
|
{
|
||||||
searchStart = FindSearchStart(episode);
|
searchStart = TimeSpan.FromTicks(lastSuitableChapter.StartPositionTicks).TotalSeconds;
|
||||||
|
isFirstEpisode = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
credit = AnalyzeMediaFile(
|
if (isFirstEpisode)
|
||||||
episode,
|
{
|
||||||
searchStart,
|
var scanTime = episode.Duration - searchStart;
|
||||||
_config.BlackFrameMinimumPercentage);
|
var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here.
|
||||||
|
|
||||||
|
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
|
||||||
|
|
||||||
|
while (frames.Length > 0) // While black frames are found increase searchStart
|
||||||
|
{
|
||||||
|
searchStart += searchDistance;
|
||||||
|
|
||||||
|
scanTime = episode.Duration - searchStart;
|
||||||
|
tr = new TimeRange(scanTime - 0.5, scanTime);
|
||||||
|
|
||||||
|
frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
|
||||||
|
|
||||||
|
if (searchStart > creditDuration)
|
||||||
|
{
|
||||||
|
searchStart = creditDuration;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (credit is null || !credit.Valid)
|
if (searchStart == _minimumCreditsDuration) // Skip if no black frames were found
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
episode.IsAnalyzed = true;
|
isFirstEpisode = false;
|
||||||
await Plugin.Instance!.UpdateTimestampAsync(credit, mode).ConfigureAwait(false);
|
|
||||||
searchStart = episode.Duration - credit.Start + _config.MinimumCreditsDuration;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return analysisQueue;
|
var credit = AnalyzeMediaFile(
|
||||||
|
episode,
|
||||||
|
searchStart,
|
||||||
|
searchDistance,
|
||||||
|
_blackFrameMinimumPercentage);
|
||||||
|
|
||||||
|
if (credit is null || !credit.Valid)
|
||||||
|
{
|
||||||
|
// If no credits were found, reset the first-episode search logic for the next episode in the sequence.
|
||||||
|
searchStart = _minimumCreditsDuration;
|
||||||
|
isFirstEpisode = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchStart = episode.Duration - credit.Start + (0.5 * searchDistance);
|
||||||
|
|
||||||
|
creditTimes.Add(episode.EpisodeId, credit);
|
||||||
|
episode.State.SetAnalyzed(mode, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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,17 +189,29 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If cancellation was requested, report that no episodes were analyzed.
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
return analysisQueue;
|
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>
|
||||||
/// Analyze two episodes to find an introduction sequence shared between them.
|
/// Analyze two episodes to find an introduction sequence shared between them.
|
||||||
/// </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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
35
IntroSkipper/Analyzers/SegmentAnalyzer.cs
Normal file
35
IntroSkipper/Analyzers/SegmentAnalyzer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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.
|
||||||
|
@ -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
@ -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" };
|
||||||
|
@ -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 timestamp = Plugin.GetIntroByMode(id, mode);
|
||||||
var config = Plugin.Instance.Configuration;
|
|
||||||
|
|
||||||
foreach (var (mode, timestamp) in timestamps)
|
// Operate on a copy to avoid mutating the original Intro object stored in the dictionary.
|
||||||
{
|
|
||||||
if (!timestamp.Valid)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new Intro to avoid mutating the original stored in 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var existingSegments = db.DbSegment.Where(s => s.ItemId == episode.EpisodeId);
|
|
||||||
|
|
||||||
db.DbSegment.RemoveRange(existingSegments);
|
|
||||||
|
|
||||||
if (eraseCache)
|
if (eraseCache)
|
||||||
{
|
{
|
||||||
await Task.Run(() => FFmpegWrapper.DeleteEpisodeCache(episode.EpisodeId), cancellationToken).ConfigureAwait(false);
|
FFmpegWrapper.DeleteEpisodeCache(e.EpisodeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var seasonInfo = db.DbSeasonInfo.Where(s => s.SeasonId == seasonId);
|
Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction | AnalysisMode.Credits);
|
||||||
|
|
||||||
foreach (var info in seasonInfo)
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the ignore list for the provided season.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ignoreListItem">New ignore list items.</param>
|
||||||
|
/// <param name="save">Save the ignore list.</param>
|
||||||
|
/// <returns>No content.</returns>
|
||||||
|
[HttpPost("IgnoreList/UpdateSeason")]
|
||||||
|
public ActionResult UpdateIgnoreListSeason([FromBody] IgnoreListItem ignoreListItem, bool save = true)
|
||||||
{
|
{
|
||||||
db.Entry(info).Property(s => s.EpisodeIds).CurrentValue = [];
|
if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(ignoreListItem.SeasonId))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
if (ignoreListItem.IgnoreIntro || ignoreListItem.IgnoreCredits)
|
||||||
|
|
||||||
if (Plugin.Instance.Configuration.UpdateMediaSegments)
|
|
||||||
{
|
{
|
||||||
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, cancellationToken).ConfigureAwait(false);
|
Plugin.Instance!.IgnoreList.AddOrUpdate(ignoreListItem.SeasonId, ignoreListItem, (_, _) => ignoreListItem);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Plugin.Instance!.IgnoreList.TryRemove(ignoreListItem.SeasonId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (save)
|
||||||
|
{
|
||||||
|
Plugin.Instance!.SaveIgnoreList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
|
/// <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)
|
||||||
{
|
{
|
||||||
return StatusCode(500, ex.Message);
|
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>
|
/// <summary>
|
||||||
/// Updates the analyzer actions for the provided season.
|
/// Updates the introduction timestamps for the provided episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">Update analyzer actions request.</param>
|
/// <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>
|
/// <returns>No content.</returns>
|
||||||
[HttpPost("AnalyzerActions/UpdateSeason")]
|
[HttpPost("Episode/{Id}/UpdateIntroTimestamps")]
|
||||||
public async Task<ActionResult> UpdateAnalyzerActions([FromBody] UpdateAnalyzerActionsRequest request)
|
[Obsolete("deprecated use Episode/{Id}/Timestamps")]
|
||||||
|
public ActionResult UpdateIntroTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps)
|
||||||
{
|
{
|
||||||
await Plugin.Instance!.SetAnalyzerActionAsync(request.Id, request.AnalyzerActions).ConfigureAwait(false);
|
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();
|
||||||
}
|
}
|
||||||
|
@ -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,
|
|
||||||
}
|
}
|
||||||
|
@ -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,
|
|
||||||
}
|
|
35
IntroSkipper/Data/EdlAction.cs
Normal file
35
IntroSkipper/Data/EdlAction.cs
Normal 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
|
||||||
|
}
|
53
IntroSkipper/Data/EpisodeState.cs
Normal file
53
IntroSkipper/Data/EpisodeState.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
92
IntroSkipper/Data/IgnoreListItem.cs
Normal file
92
IntroSkipper/Data/IgnoreListItem.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>();
|
|
||||||
}
|
|
||||||
}
|
|
@ -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; } = [];
|
|
||||||
}
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
|
119
IntroSkipper/Manager/EdlManager.cs
Normal file
119
IntroSkipper/Manager/EdlManager.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -14,18 +14,18 @@ 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)
|
||||||
{
|
{
|
||||||
/// <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 ILibraryManager _libraryManager = libraryManager;
|
||||||
private readonly ILogger<QueueManager> _logger = logger;
|
private readonly ILogger<QueueManager> _logger = logger;
|
||||||
private readonly Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes = [];
|
private readonly Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes = [];
|
||||||
@ -152,13 +152,10 @@ namespace IntroSkipper.Manager
|
|||||||
{
|
{
|
||||||
QueueEpisode(episode);
|
QueueEpisode(episode);
|
||||||
}
|
}
|
||||||
else if (item is Movie movie)
|
else if (_analyzeMovies && item is Movie movie)
|
||||||
{
|
|
||||||
if (_analyzeMovies)
|
|
||||||
{
|
{
|
||||||
QueueMovie(movie);
|
QueueMovie(movie);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Item {Name} is not an episode or movie", item.Name);
|
_logger.LogDebug("Item {Name} is not an episode or movie", item.Name);
|
||||||
@ -222,7 +219,6 @@ namespace IntroSkipper.Manager
|
|||||||
SeriesName = episode.SeriesName,
|
SeriesName = episode.SeriesName,
|
||||||
SeasonNumber = episode.AiredSeasonNumber ?? 0,
|
SeasonNumber = episode.AiredSeasonNumber ?? 0,
|
||||||
SeriesId = episode.SeriesId,
|
SeriesId = episode.SeriesId,
|
||||||
SeasonId = episode.SeasonId,
|
|
||||||
EpisodeId = episode.Id,
|
EpisodeId = episode.Id,
|
||||||
Name = episode.Name,
|
Name = episode.Name,
|
||||||
IsAnime = isAnime,
|
IsAnime = isAnime,
|
||||||
@ -238,7 +234,6 @@ namespace IntroSkipper.Manager
|
|||||||
private void QueueMovie(Movie movie)
|
private void QueueMovie(Movie movie)
|
||||||
{
|
{
|
||||||
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");
|
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(movie.Path))
|
if (string.IsNullOrEmpty(movie.Path))
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
@ -250,22 +245,17 @@ namespace IntroSkipper.Manager
|
|||||||
|
|
||||||
// Allocate a new list for each Movie
|
// Allocate a new list for each Movie
|
||||||
_queuedEpisodes.TryAdd(movie.Id, []);
|
_queuedEpisodes.TryAdd(movie.Id, []);
|
||||||
|
|
||||||
var duration = TimeSpan.FromTicks(movie.RunTimeTicks ?? 0).TotalSeconds;
|
var duration = TimeSpan.FromTicks(movie.RunTimeTicks ?? 0).TotalSeconds;
|
||||||
|
|
||||||
_queuedEpisodes[movie.Id].Add(new QueuedEpisode
|
_queuedEpisodes[movie.Id].Add(new QueuedEpisode
|
||||||
{
|
{
|
||||||
SeriesName = movie.Name,
|
SeriesName = movie.Name,
|
||||||
SeriesId = movie.Id,
|
SeriesId = movie.Id,
|
||||||
SeasonId = movie.Id,
|
|
||||||
EpisodeId = movie.Id,
|
EpisodeId = movie.Id,
|
||||||
Name = movie.Name,
|
Name = movie.Name,
|
||||||
Path = movie.Path,
|
Path = movie.Path,
|
||||||
Duration = Convert.ToInt32(duration),
|
Duration = Convert.ToInt32(duration),
|
||||||
CreditsFingerprintStart = Convert.ToInt32(duration - pluginInstance.Configuration.MaximumMovieCreditsDuration),
|
|
||||||
IsMovie = true
|
IsMovie = true
|
||||||
});
|
});
|
||||||
|
|
||||||
pluginInstance.TotalQueued++;
|
pluginInstance.TotalQueued++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -294,19 +284,18 @@ namespace IntroSkipper.Manager
|
|||||||
/// <param name="candidates">Queued media items.</param>
|
/// <param name="candidates">Queued media items.</param>
|
||||||
/// <param name="modes">Analysis mode.</param>
|
/// <param name="modes">Analysis mode.</param>
|
||||||
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
|
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
|
||||||
internal (IReadOnlyList<QueuedEpisode> QueuedEpisodes, IReadOnlyCollection<AnalysisMode> RequiredModes)
|
public (IReadOnlyList<QueuedEpisode> VerifiedItems, IReadOnlyCollection<AnalysisMode> RequiredModes)
|
||||||
VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<AnalysisMode> modes)
|
VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<AnalysisMode> modes)
|
||||||
{
|
{
|
||||||
var verified = new List<QueuedEpisode>();
|
var verified = new List<QueuedEpisode>();
|
||||||
var requiredModes = new HashSet<AnalysisMode>();
|
var reqModes = new HashSet<AnalysisMode>();
|
||||||
|
|
||||||
var episodeIds = Plugin.Instance!.GetEpisodeIds(candidates[0].SeasonId);
|
|
||||||
|
|
||||||
foreach (var candidate in candidates)
|
foreach (var candidate in candidates)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
|
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
|
||||||
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@ -316,9 +305,22 @@ namespace IntroSkipper.Manager
|
|||||||
|
|
||||||
foreach (var mode in modes)
|
foreach (var mode in modes)
|
||||||
{
|
{
|
||||||
if (!episodeIds.TryGetValue(mode, out var ids) || !ids.Contains(candidate.EpisodeId) || Plugin.Instance!.AnalyzeAgain)
|
if (candidate.State.IsAnalyzed(mode) || candidate.State.IsBlacklisted(mode))
|
||||||
{
|
{
|
||||||
requiredModes.Add(mode);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isAnalyzed = mode == AnalysisMode.Introduction
|
||||||
|
? Plugin.Instance!.Intros.ContainsKey(candidate.EpisodeId)
|
||||||
|
: Plugin.Instance!.Credits.ContainsKey(candidate.EpisodeId);
|
||||||
|
|
||||||
|
if (isAnalyzed)
|
||||||
|
{
|
||||||
|
candidate.State.SetAnalyzed(mode, true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
reqModes.Add(mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -332,7 +334,6 @@ namespace IntroSkipper.Manager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (verified, requiredModes);
|
return (verified, reqModes);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
try
|
||||||
{
|
{
|
||||||
using var db = new IntroSkipperDbContext(_dbPath);
|
XmlSerializer serializer = new XmlSerializer(typeof(PluginConfiguration));
|
||||||
db.ApplyMigrations();
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Error initializing database: {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
|
||||||
|
{
|
||||||
|
RestoreTimestamps();
|
||||||
|
}
|
||||||
|
catch (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();
|
||||||
|
|
||||||
|
// check if old plugins exits
|
||||||
|
if (pluginRepositories.Exists(repo => repo.Url != null && oldRepos.Contains(repo.Url)))
|
||||||
{
|
{
|
||||||
db.Entry(existing).CurrentValues.SetValues(dbSegment);
|
// remove all old plugins
|
||||||
}
|
pluginRepositories.RemoveAll(repo => repo.Url != null && oldRepos.Contains(repo.Url));
|
||||||
else
|
|
||||||
|
// 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)
|
||||||
{
|
{
|
||||||
db.DbSegment.Add(dbSegment);
|
// 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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync().ConfigureAwait(false);
|
// 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);
|
||||||
|
|
||||||
|
// should be only one file but this safer
|
||||||
|
foreach (var file in filePaths)
|
||||||
|
{
|
||||||
|
string dashBoardText = File.ReadAllText(file);
|
||||||
|
// Perform the match
|
||||||
|
Match match = regex.Match(dashBoardText);
|
||||||
|
// search for buildVersion and webVersion
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
webVersionString = match.Groups["webVersion"].Value;
|
||||||
|
_logger.LogInformation("Found jellyfin-web <{WebVersion}>", webVersionString);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task CleanTimestamps(IEnumerable<Guid> episodeIds)
|
if (webVersionString != "unknown")
|
||||||
{
|
{
|
||||||
using var db = new IntroSkipperDbContext(_dbPath);
|
// append Revision
|
||||||
db.DbSegment.RemoveRange(db.DbSegment
|
webVersionString += ".0";
|
||||||
.Where(s => !episodeIds.Contains(s.ItemId)));
|
if (Version.TryParse(webVersionString, out var webversion))
|
||||||
await db.SaveChangesAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task SetAnalyzerActionAsync(Guid id, IReadOnlyDictionary<AnalysisMode, AnalyzerAction> analyzerActions)
|
|
||||||
{
|
{
|
||||||
using var db = new IntroSkipperDbContext(_dbPath);
|
if (_applicationHost.ApplicationVersion != webversion)
|
||||||
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))
|
_logger.LogWarning("The jellyfin-web <{WebVersion}> NOT compatible with Jellyfin <{JellyfinVersion}>", webVersionString, _applicationHost.ApplicationVersion);
|
||||||
{
|
|
||||||
db.Entry(existing).Property(s => s.Action).CurrentValue = action;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
db.DbSeasonInfo.Add(new DbSeasonInfo(id, mode, action));
|
_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 async Task SetEpisodeIdsAsync(Guid id, AnalysisMode mode, IEnumerable<Guid> episodeIds)
|
// should be only one file but this safer
|
||||||
|
foreach (var file in filePaths)
|
||||||
{
|
{
|
||||||
using var db = new IntroSkipperDbContext(_dbPath);
|
// search for class btnSkipIntro
|
||||||
var seasonInfo = db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode);
|
if (File.ReadAllText(file).Contains("btnSkipIntro", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|
||||||
if (seasonInfo is null)
|
|
||||||
{
|
{
|
||||||
seasonInfo = new DbSeasonInfo(id, mode, AnalyzerAction.Default, episodeIds);
|
_logger.LogInformation("Found a modified version of jellyfin-web with built-in skip button support.");
|
||||||
db.DbSeasonInfo.Add(seasonInfo);
|
return;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
db.Entry(seasonInfo).Property(s => s.EpisodeIds).CurrentValue = episodeIds;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync().ConfigureAwait(false);
|
// Inject the skip intro button code into the web interface.
|
||||||
|
string indexPath = Path.Join(webPath, "index.html");
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
_logger.LogDebug("Reading index.html from {Path}", indexPath);
|
||||||
|
string contents = File.ReadAllText(indexPath);
|
||||||
|
|
||||||
|
// change URL with every relase to prevent the Browers from caching
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal IReadOnlyDictionary<AnalysisMode, IEnumerable<Guid>> GetEpisodeIds(Guid id)
|
// remove old version if necessary
|
||||||
{
|
pattern = @"<script src=""configurationpage\?name=skip-intro-button\.js.*<\/script>";
|
||||||
using var db = new IntroSkipperDbContext(_dbPath);
|
contents = Regex.Replace(contents, pattern, string.Empty, RegexOptions.IgnoreCase);
|
||||||
return db.DbSeasonInfo.Where(s => s.SeasonId == id)
|
|
||||||
.ToDictionary(s => s.Type, s => s.EpisodeIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal AnalyzerAction GetAnalyzerAction(Guid id, AnalysisMode mode)
|
// 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.
|
||||||
using var db = new IntroSkipperDbContext(_dbPath);
|
Regex headEnd = new Regex(@"</head>", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
|
||||||
return db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode)?.Action ?? AnalyzerAction.Default;
|
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task CleanSeasonInfoAsync(IEnumerable<Guid> ids)
|
// Write the modified file contents
|
||||||
{
|
File.WriteAllText(indexPath, contents);
|
||||||
using var db = new IntroSkipperDbContext(_dbPath);
|
|
||||||
var obsoleteSeasons = await db.DbSeasonInfo
|
_logger.LogInformation("Skip button added successfully.");
|
||||||
.Where(s => !ids.Contains(s.SeasonId))
|
|
||||||
.ToListAsync().ConfigureAwait(false);
|
|
||||||
db.DbSeasonInfo.RemoveRange(obsoleteSeasons);
|
|
||||||
await db.SaveChangesAsync().ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
private readonly IReadOnlyCollection<AnalysisMode> _analysisModes;
|
||||||
/// <param name="logger">Task logger.</param>
|
|
||||||
/// <param name="loggerFactory">Logger factory.</param>
|
private readonly ILogger _logger;
|
||||||
/// <param name="libraryManager">Library manager.</param>
|
|
||||||
/// <param name="mediaSegmentUpdateManager">MediaSegmentUpdateManager.</param>
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
public class BaseItemAnalyzerTask(
|
|
||||||
|
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,
|
ILogger logger,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager)
|
||||||
MediaSegmentUpdateManager mediaSegmentUpdateManager)
|
{
|
||||||
{
|
_analysisModes = modes;
|
||||||
private readonly ILogger _logger = logger;
|
_logger = logger;
|
||||||
private readonly ILoggerFactory _loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
private readonly ILibraryManager _libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
|
||||||
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
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}",
|
||||||
catch (Exception ex)
|
first.SeriesName,
|
||||||
{
|
first.SeasonNumber,
|
||||||
_logger.LogError(ex, "An unexpected error occurred during analysis.");
|
ex);
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_config.RebuildMediaSegments || (updateMediaSegments && _config.UpdateMediaSegments))
|
if (writeEdl && Plugin.Instance.Configuration.EdlAction != EdlAction.None)
|
||||||
{
|
{
|
||||||
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, ct).ConfigureAwait(false);
|
EdlManager.UpdateEDLFiles(episodes);
|
||||||
}
|
}
|
||||||
}).ConfigureAwait(false);
|
});
|
||||||
|
|
||||||
Plugin.Instance!.AnalyzeAgain = false;
|
if (Plugin.Instance.Configuration.RegenerateEdlFiles)
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
109
IntroSkipper/ScheduledTasks/DetectCreditsTask.cs
Normal file
109
IntroSkipper/ScheduledTasks/DetectCreditsTask.cs
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
108
IntroSkipper/ScheduledTasks/DetectIntrosTask.cs
Normal file
108
IntroSkipper/ScheduledTasks/DetectIntrosTask.cs
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,129 +16,137 @@ 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>
|
/// <summary>
|
||||||
/// Automatically skip past introduction sequences.
|
/// Automatically skip past introduction sequences.
|
||||||
/// Commands clients to seek to the end of the intro as soon as they start playing it.
|
/// Commands clients to seek to the end of the intro as soon as they start playing it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
|
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="userDataManager">User data manager.</param>
|
/// <param name="userDataManager">User data manager.</param>
|
||||||
/// <param name="sessionManager">Session manager.</param>
|
/// <param name="sessionManager">Session manager.</param>
|
||||||
/// <param name="logger">Logger.</param>
|
/// <param name="logger">Logger.</param>
|
||||||
public sealed class AutoSkip(
|
public class AutoSkip(
|
||||||
IUserDataManager userDataManager,
|
IUserDataManager userDataManager,
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
ILogger<AutoSkip> logger) : IHostedService, IDisposable
|
ILogger<AutoSkip> logger) : IHostedService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IUserDataManager _userDataManager = userDataManager;
|
private readonly object _sentSeekCommandLock = new();
|
||||||
private readonly ISessionManager _sessionManager = sessionManager;
|
|
||||||
private readonly ILogger<AutoSkip> _logger = logger;
|
private ILogger<AutoSkip> _logger = logger;
|
||||||
private readonly System.Timers.Timer _playbackTimer = new(1000);
|
private IUserDataManager _userDataManager = userDataManager;
|
||||||
private readonly ConcurrentDictionary<string, List<Intro>> _sentSeekCommand = [];
|
private ISessionManager _sessionManager = sessionManager;
|
||||||
private PluginConfiguration _config = new();
|
private Timer _playbackTimer = new(1000);
|
||||||
|
private Dictionary<string, bool> _sentSeekCommand = [];
|
||||||
private HashSet<string> _clientList = [];
|
private HashSet<string> _clientList = [];
|
||||||
private HashSet<AnalysisMode> _segmentTypes = [];
|
|
||||||
private bool _autoSkipEnabled;
|
|
||||||
|
|
||||||
private void AutoSkipChanged(object? sender, BasePluginConfiguration e)
|
private void AutoSkipChanged(object? sender, BasePluginConfiguration e)
|
||||||
{
|
{
|
||||||
_config = (PluginConfiguration)e;
|
var configuration = (PluginConfiguration)e;
|
||||||
_clientList = [.. _config.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
_clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||||
_segmentTypes = [.. _config.TypeList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(Enum.Parse<AnalysisMode>)];
|
var newState = configuration.AutoSkip || (configuration.SkipButtonVisible && _clientList.Count > 0);
|
||||||
_autoSkipEnabled = (_config.AutoSkip || _clientList.Count > 0) && _segmentTypes.Count > 0;
|
_logger.LogDebug("Setting playback timer enabled to {NewState}", newState);
|
||||||
_logger.LogDebug("Setting playback timer enabled to {AutoSkipEnabled}", _autoSkipEnabled);
|
_playbackTimer.Enabled = newState;
|
||||||
_playbackTimer.Enabled = _autoSkipEnabled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
|
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
|
||||||
{
|
{
|
||||||
// Ignore all events except playback start & end
|
|
||||||
if (e.SaveReason is not (UserDataSaveReason.PlaybackStart or UserDataSaveReason.PlaybackFinished) || !_autoSkipEnabled)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var itemId = e.Item.Id;
|
var itemId = e.Item.Id;
|
||||||
var session = _sessionManager.Sessions
|
var newState = false;
|
||||||
.FirstOrDefault(s => s.UserId == e.UserId && s.NowPlayingItem?.Id == itemId);
|
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
|
||||||
|
|
||||||
if (session is null)
|
// Ignore all events except playback start & end
|
||||||
|
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
|
||||||
{
|
{
|
||||||
// Clean up orphaned sessions
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
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.
|
// 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)
|
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
|
||||||
{
|
{
|
||||||
foreach (var session in _sessionManager.Sessions.Where(s => _config.AutoSkip || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase)))
|
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 deviceId = session.DeviceId;
|
||||||
|
var itemId = session.NowPlayingItem.Id;
|
||||||
// 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 position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
|
||||||
|
|
||||||
var currentIntro = intros.FirstOrDefault(i =>
|
// Don't send the seek command more than once in the same session.
|
||||||
position >= Math.Max(1, i.IntroStart + _config.SecondsOfIntroStartToPlay) &&
|
lock (_sentSeekCommandLock)
|
||||||
position < i.IntroEnd - 3.0); // 3 seconds before the end of the intro
|
{
|
||||||
|
if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("Already sent seek command for session {Session}", deviceId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (currentIntro is null)
|
// Assert that an intro was detected for this item.
|
||||||
|
if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var introEnd = currentIntro.IntroEnd;
|
// Seek is unreliable if called at the very start of an episode.
|
||||||
|
var adjustedStart = Math.Max(1, intro.Start + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay);
|
||||||
intros.Remove(currentIntro);
|
var adjustedEnd = intro.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro;
|
||||||
|
|
||||||
// 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(
|
_logger.LogTrace(
|
||||||
"Playback position is {Position}",
|
"Playback position is {Position}, intro runs from {Start} to {End}",
|
||||||
position);
|
position,
|
||||||
|
adjustedStart,
|
||||||
|
adjustedEnd);
|
||||||
|
|
||||||
|
if (position < adjustedStart || position > adjustedEnd)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Notify the user that an introduction is being skipped for them.
|
// Notify the user that an introduction is being skipped for them.
|
||||||
var notificationText = _config.AutoSkipNotificationText;
|
var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(notificationText))
|
if (!string.IsNullOrWhiteSpace(notificationText))
|
||||||
{
|
{
|
||||||
_sessionManager.SendMessageCommand(
|
_sessionManager.SendMessageCommand(
|
||||||
@ -164,20 +170,40 @@ namespace IntroSkipper.Services
|
|||||||
{
|
{
|
||||||
Command = PlaystateCommand.Seek,
|
Command = PlaystateCommand.Seek,
|
||||||
ControllingUserId = session.UserId.ToString(),
|
ControllingUserId = session.UserId.ToString(),
|
||||||
SeekPositionTicks = (long)introEnd * TimeSpan.TicksPerSecond,
|
SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond,
|
||||||
},
|
},
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Flag that we've sent the seek command so that it's not sent repeatedly
|
// 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);
|
_logger.LogTrace("Setting seek command state for session {Session}", deviceId);
|
||||||
|
_sentSeekCommand[deviceId] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dispose resources.
|
/// Dispose.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Dispose()
|
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();
|
_playbackTimer.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,9 +228,6 @@ namespace IntroSkipper.Services
|
|||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
|
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
|
||||||
Plugin.Instance!.ConfigurationChanged -= AutoSkipChanged;
|
|
||||||
_playbackTimer.Stop();
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
233
IntroSkipper/Services/AutoSkipCredits.cs
Normal file
233
IntroSkipper/Services/AutoSkipCredits.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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,21 +16,20 @@ 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
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Server entrypoint.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Entrypoint : IHostedService, IDisposable
|
|
||||||
{
|
|
||||||
private readonly ITaskManager _taskManager;
|
private readonly ITaskManager _taskManager;
|
||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly ILogger<Entrypoint> _logger;
|
private readonly ILogger<Entrypoint> _logger;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager;
|
|
||||||
private readonly HashSet<Guid> _seasonsToAnalyze = [];
|
private readonly HashSet<Guid> _seasonsToAnalyze = [];
|
||||||
private readonly Timer _queueTimer;
|
private readonly Timer _queueTimer;
|
||||||
private static readonly SemaphoreSlim _analysisSemaphore = new(1, 1);
|
private static readonly ManualResetEventSlim _autoTaskCompletEvent = new(false);
|
||||||
private PluginConfiguration _config;
|
private PluginConfiguration _config;
|
||||||
private bool _analyzeAgain;
|
private bool _analyzeAgain;
|
||||||
private static CancellationTokenSource? _cancellationTokenSource;
|
private static CancellationTokenSource? _cancellationTokenSource;
|
||||||
@ -43,19 +41,16 @@ namespace IntroSkipper.Services
|
|||||||
/// <param name="taskManager">Task manager.</param>
|
/// <param name="taskManager">Task manager.</param>
|
||||||
/// <param name="logger">Logger.</param>
|
/// <param name="logger">Logger.</param>
|
||||||
/// <param name="loggerFactory">Logger factory.</param>
|
/// <param name="loggerFactory">Logger factory.</param>
|
||||||
/// <param name="mediaSegmentUpdateManager">MediaSegment Update Manager.</param>
|
|
||||||
public Entrypoint(
|
public Entrypoint(
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
ITaskManager taskManager,
|
ITaskManager taskManager,
|
||||||
ILogger<Entrypoint> logger,
|
ILogger<Entrypoint> logger,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory)
|
||||||
MediaSegmentUpdateManager mediaSegmentUpdateManager)
|
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_taskManager = taskManager;
|
_taskManager = taskManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
|
||||||
|
|
||||||
_config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
_config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
_queueTimer = new Timer(
|
_queueTimer = new Timer(
|
||||||
@ -68,26 +63,42 @@ namespace IntroSkipper.Services
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets State of the automatic task.
|
/// Gets State of the automatic task.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TaskState AutomaticTaskState => _cancellationTokenSource switch
|
public static TaskState AutomaticTaskState
|
||||||
{
|
{
|
||||||
null => TaskState.Idle,
|
get
|
||||||
{ IsCancellationRequested: true } => TaskState.Cancelling,
|
{
|
||||||
_ => TaskState.Running
|
if (_cancellationTokenSource is not null)
|
||||||
};
|
{
|
||||||
|
return _cancellationTokenSource.IsCancellationRequested
|
||||||
|
? TaskState.Cancelling
|
||||||
|
: TaskState.Running;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskState.Idle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_libraryManager.ItemAdded += OnItemChanged;
|
_libraryManager.ItemAdded += OnItemAdded;
|
||||||
_libraryManager.ItemUpdated += OnItemChanged;
|
_libraryManager.ItemUpdated += OnItemModified;
|
||||||
_taskManager.TaskCompleted += OnLibraryRefresh;
|
_taskManager.TaskCompleted += OnLibraryRefresh;
|
||||||
Plugin.Instance!.ConfigurationChanged += OnSettingsChanged;
|
Plugin.Instance!.ConfigurationChanged += OnSettingsChanged;
|
||||||
|
|
||||||
FFmpegWrapper.Logger = _logger;
|
FFmpegWrapper.Logger = _logger;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
// 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();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError("Unable to run startup enqueue: {Exception}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@ -95,33 +106,75 @@ namespace IntroSkipper.Services
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_libraryManager.ItemAdded -= OnItemChanged;
|
_libraryManager.ItemAdded -= OnItemAdded;
|
||||||
_libraryManager.ItemUpdated -= OnItemChanged;
|
_libraryManager.ItemUpdated -= OnItemModified;
|
||||||
_taskManager.TaskCompleted -= OnLibraryRefresh;
|
_taskManager.TaskCompleted -= OnLibraryRefresh;
|
||||||
Plugin.Instance!.ConfigurationChanged -= OnSettingsChanged;
|
|
||||||
|
|
||||||
|
// Stop the timer
|
||||||
_queueTimer.Change(Timeout.Infinite, 0);
|
_queueTimer.Change(Timeout.Infinite, 0);
|
||||||
return Task.CompletedTask;
|
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>
|
/// <summary>
|
||||||
/// Library item was added.
|
/// Library item was added.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sender">The sending entity.</param>
|
/// <param name="sender">The sending entity.</param>
|
||||||
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
|
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
|
||||||
private void OnItemChanged(object? sender, ItemChangeEventArgs itemChangeEventArgs)
|
private void OnItemAdded(object? sender, ItemChangeEventArgs itemChangeEventArgs)
|
||||||
{
|
{
|
||||||
if (_config.AutoDetectIntros &&
|
// Don't do anything if auto detection is disabled
|
||||||
itemChangeEventArgs.Item is { LocationType: not LocationType.Virtual } item)
|
if (!_config.AutoDetectIntros && !_config.AutoDetectCredits)
|
||||||
{
|
{
|
||||||
Guid? id = item is Episode episode ? episode.SeasonId : (item is Movie movie ? movie.Id : null);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (id.HasValue)
|
// Don't do anything if it's not a supported media type
|
||||||
|
if (itemChangeEventArgs.Item is not Episode episode)
|
||||||
{
|
{
|
||||||
_seasonsToAnalyze.Add(id.Value);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_seasonsToAnalyze.Add(episode.SeasonId);
|
||||||
|
|
||||||
StartTimer();
|
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>
|
/// <summary>
|
||||||
@ -131,20 +184,35 @@ namespace IntroSkipper.Services
|
|||||||
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
|
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
|
||||||
private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs)
|
private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs)
|
||||||
{
|
{
|
||||||
if (_config.AutoDetectIntros &&
|
// Don't do anything if auto detection is disabled
|
||||||
eventArgs.Result is { Key: "RefreshLibrary", Status: TaskCompletionStatus.Completed } &&
|
if (!_config.AutoDetectIntros && !_config.AutoDetectCredits)
|
||||||
AutomaticTaskState != TaskState.Running)
|
|
||||||
{
|
{
|
||||||
StartTimer();
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSettingsChanged(object? sender, BasePluginConfiguration e)
|
var result = eventArgs.Result;
|
||||||
|
|
||||||
|
if (result.Key != "RefreshLibrary")
|
||||||
{
|
{
|
||||||
_config = (PluginConfiguration)e;
|
return;
|
||||||
Plugin.Instance!.AnalyzeAgain = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
/// <summary>
|
||||||
/// Start timer to debounce analyzing.
|
/// Start timer to debounce analyzing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -157,72 +225,90 @@ namespace IntroSkipper.Services
|
|||||||
else if (AutomaticTaskState == TaskState.Idle)
|
else if (AutomaticTaskState == TaskState.Idle)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Media Library changed, analyzis will start soon!");
|
_logger.LogDebug("Media Library changed, analyzis will start soon!");
|
||||||
_queueTimer.Change(TimeSpan.FromSeconds(60), Timeout.InfiniteTimeSpan);
|
_queueTimer.Change(TimeSpan.FromMilliseconds(20000), Timeout.InfiniteTimeSpan);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTimerCallback(object? state) =>
|
/// <summary>
|
||||||
_ = RunAnalysisAsync();
|
/// Wait for timer callback to be completed.
|
||||||
|
/// </summary>
|
||||||
private async Task RunAnalysisAsync()
|
private void OnTimerCallback(object? state)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await PerformAnalysisAsync().ConfigureAwait(false);
|
PerformAnalysis();
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Automatic Analysis task cancelled");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error in RunAnalysisAsync");
|
_logger.LogError(ex, "Error in PerformAnalysis");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
_cancellationTokenSource = null;
|
_cancellationTokenSource = null;
|
||||||
|
_autoTaskCompletEvent.Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PerformAnalysisAsync()
|
/// <summary>
|
||||||
{
|
/// Wait for timer to be completed.
|
||||||
await _analysisSemaphore.WaitAsync().ConfigureAwait(false);
|
/// </summary>
|
||||||
try
|
private void PerformAnalysis()
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Initiate automatic analysis task.");
|
||||||
|
_autoTaskCompletEvent.Reset();
|
||||||
|
|
||||||
using (_cancellationTokenSource = new CancellationTokenSource())
|
using (_cancellationTokenSource = new CancellationTokenSource())
|
||||||
using (await ScheduledTaskSemaphore.AcquireAsync(_cancellationTokenSource.Token).ConfigureAwait(false))
|
using (ScheduledTaskSemaphore.Acquire(_cancellationTokenSource.Token))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Initiating automatic analysis task");
|
|
||||||
var seasonIds = new HashSet<Guid>(_seasonsToAnalyze);
|
var seasonIds = new HashSet<Guid>(_seasonsToAnalyze);
|
||||||
_seasonsToAnalyze.Clear();
|
_seasonsToAnalyze.Clear();
|
||||||
|
|
||||||
_analyzeAgain = false;
|
_analyzeAgain = false;
|
||||||
|
var progress = new Progress<double>();
|
||||||
|
var modes = new List<AnalysisMode>();
|
||||||
|
var tasklogger = _loggerFactory.CreateLogger("DefaultLogger");
|
||||||
|
|
||||||
var analyzer = new BaseItemAnalyzerTask(_loggerFactory.CreateLogger<Entrypoint>(), _loggerFactory, _libraryManager, _mediaSegmentUpdateManager);
|
if (_config.AutoDetectIntros)
|
||||||
await analyzer.AnalyzeItemsAsync(new Progress<double>(), _cancellationTokenSource.Token, seasonIds).ConfigureAwait(false);
|
{
|
||||||
|
modes.Add(AnalysisMode.Introduction);
|
||||||
|
tasklogger = _loggerFactory.CreateLogger<DetectIntrosTask>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_config.AutoDetectCredits)
|
||||||
|
{
|
||||||
|
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)
|
if (_analyzeAgain && !_cancellationTokenSource.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Analyzing ended, but we need to analyze again!");
|
_logger.LogInformation("Analyzing ended, but we need to analyze again!");
|
||||||
_queueTimer.Change(TimeSpan.FromSeconds(60), Timeout.InfiniteTimeSpan);
|
StartTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
_analysisSemaphore.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Method to cancel the automatic task.
|
/// Method to cancel the automatic task.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
public static void CancelAutomaticTask(CancellationToken cancellationToken)
|
||||||
public static async Task CancelAutomaticTaskAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
if (_cancellationTokenSource is { IsCancellationRequested: false })
|
if (_cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
|
_cancellationTokenSource.Cancel();
|
||||||
}
|
}
|
||||||
catch (ObjectDisposedException)
|
catch (ObjectDisposedException)
|
||||||
{
|
{
|
||||||
@ -230,7 +316,7 @@ namespace IntroSkipper.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _analysisSemaphore.WaitAsync(TimeSpan.FromSeconds(60), cancellationToken).ConfigureAwait(false);
|
_autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@ -238,7 +324,6 @@ namespace IntroSkipper.Services
|
|||||||
{
|
{
|
||||||
_queueTimer.Dispose();
|
_queueTimer.Dispose();
|
||||||
_cancellationTokenSource?.Dispose();
|
_cancellationTokenSource?.Dispose();
|
||||||
_analysisSemaphore.Dispose();
|
_autoTaskCompletEvent.Dispose();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
20
README.md
20
README.md
@ -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)
|
||||||
|
|
||||||
|
@ -1 +1 @@
|
|||||||
10.10
|
10.9
|
16
docs/edl.md
Normal file
16
docs/edl.md
Normal 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.
|
@ -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
226
webui.patch
Normal 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;
|
||||||
|
+}
|
Loading…
x
Reference in New Issue
Block a user