Compare commits
131 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
3139c15eb1 | ||
|
d7d3949887 | ||
|
18d0847ae0 | ||
|
724c237592 | ||
|
1048eaf26d | ||
|
4995fc7b70 | ||
|
d4f88e0f3e | ||
|
cb0fdb92ad | ||
|
87ee56de65 | ||
|
9fce12bdbb | ||
|
4293f17dd4 | ||
|
e86832b571 | ||
|
a89e61b919 | ||
|
89d3fe79ec | ||
|
62683ede87 | ||
|
d48ea90190 | ||
|
6ccf002e51 | ||
|
6aa26fe9a7 | ||
|
b511d9045e | ||
|
a51bf5552b | ||
|
f275e188da | ||
|
7309be0422 | ||
|
78e8943061 | ||
|
86f2c7e04c | ||
|
fcaff967f0 | ||
|
0b31117772 | ||
|
6bb54ab3a5 | ||
|
72366e93b9 | ||
|
247a5793d8 | ||
|
e5f29a91c9 | ||
|
f70207d002 | ||
|
ec4863dd5d | ||
|
dcb034ff89 | ||
|
1304acc312 | ||
|
f68fd9d06a | ||
|
327fe99de0 | ||
|
6c64d48fe5 | ||
|
79151fa893 | ||
|
86dec1478a | ||
|
48a087d437 | ||
|
16b834b553 | ||
|
b9194334c6 | ||
|
da935524bd | ||
|
af1536fc28 | ||
|
97b7585efb | ||
|
9e2bbd0c0f | ||
|
4598918bd7 | ||
|
d5043d3f04 | ||
|
71768972e5 | ||
|
9af182fe98 | ||
|
94da294316 | ||
|
a1d634b66e | ||
|
29ca500ef1 | ||
|
c2eaed2f63 | ||
|
55c6415022 | ||
|
7db0f774e9 | ||
|
abb4cf44f4 | ||
|
27c4843904 | ||
|
69960bfa5b | ||
|
87875c8a11 | ||
|
f68cc3a68b | ||
|
1e93a3dca7 | ||
|
cb588df512 | ||
|
ce922d1b79 | ||
|
b286c78153 | ||
|
6d93df1c76 | ||
|
718a86945e | ||
|
6ff2185f6c | ||
|
c4347a374a | ||
|
ccdea752ef | ||
|
964822cd94 | ||
|
fc400560e6 | ||
|
e9131b6710 | ||
|
3e25778173 | ||
|
f762f0f1a2 | ||
|
1019cc30fb | ||
|
0e7d3b6aee | ||
|
2f7c2ed95e | ||
|
28f93a8569 | ||
|
479f6401f1 | ||
|
03480eaa12 | ||
|
1cc80604c2 | ||
|
b38bd7bb8c | ||
|
6f79e5d2c5 | ||
|
c663e1dfde | ||
|
b12433d621 | ||
|
6376e72862 | ||
|
f282e51308 | ||
|
299db8d77e | ||
|
21cf2d5e46 | ||
|
05038b6ead | ||
|
e20f87bb3b | ||
|
5f9a308dca | ||
|
f47f05c04e | ||
|
d9460b16fb | ||
|
0b27b6e297 | ||
|
4b725aaaad | ||
|
b02403da84 | ||
|
721de97cc1 | ||
|
84da3f0a29 | ||
|
5205965d0d | ||
|
aa1e0b8966 | ||
|
0735b41f3d | ||
|
00b272e77e | ||
|
1a731e3acc | ||
|
8f7c63172f | ||
|
3c2125ab82 | ||
|
5bc8913668 | ||
|
f6c8fca28f | ||
|
7e65ad1685 | ||
|
fc830a5e6f | ||
|
ca9a167ad5 | ||
|
29ee3e0bc8 | ||
|
73287b79a5 | ||
|
b95a40b16e | ||
|
f14fd06b43 | ||
|
dc5fc951ed | ||
|
ccabdaff19 | ||
|
c22fea2add | ||
|
f5b3fdc324 | ||
|
92ce2fb704 | ||
|
44a121cb64 | ||
|
8b06af0444 | ||
|
0092b2a7bb | ||
|
a2c3409eff | ||
|
0fbbcf9c84 | ||
|
3d8c08fcde | ||
|
50ab82a2e0 | ||
|
75352dd1c4 | ||
|
6c04f26407 | ||
|
71e8644483 |
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.
|
||||
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:
|
||||
- label: I use Jellyfin 10.9.11 (or newer) and my permissions are correct
|
||||
- label: I use Jellyfin 10.10.3 (or newer) and my permissions are correct
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@ -55,13 +55,13 @@ jobs:
|
||||
run: dotnet restore
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
|
||||
uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
|
||||
uses: github/codeql-action/autobuild@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
|
||||
uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
||||
|
@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
@ -31,8 +31,7 @@ public class TestAudioFingerprinting
|
||||
[InlineData(19, 2_465_585_877)]
|
||||
public void TestBitCounting(int expectedBits, uint number)
|
||||
{
|
||||
var chromaprint = CreateChromaprintAnalyzer();
|
||||
Assert.Equal(expectedBits, chromaprint.CountBits(number));
|
||||
Assert.Equal(expectedBits, ChromaprintAnalyzer.CountBits(number));
|
||||
}
|
||||
|
||||
[FactSkipFFmpegTests]
|
||||
@ -86,7 +85,8 @@ public class TestAudioFingerprinting
|
||||
{77, 5},
|
||||
};
|
||||
|
||||
var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction);
|
||||
var analyzer = CreateChromaprintAnalyzer();
|
||||
var actual = analyzer.CreateInvertedIndex(Guid.NewGuid(), fpr);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
@ -127,12 +127,12 @@ public class TestAudioFingerprinting
|
||||
|
||||
var expected = new TimeRange[]
|
||||
{
|
||||
new(44.6310, 44.8072),
|
||||
new(53.5905, 53.8070),
|
||||
new(53.8458, 54.2024),
|
||||
new(54.2611, 54.5935),
|
||||
new(54.7098, 54.9293),
|
||||
new(54.9294, 55.2590),
|
||||
new(44.631042, 44.807167),
|
||||
new(53.590521, 53.806979),
|
||||
new(53.845833, 54.202417),
|
||||
new(54.261104, 54.593479),
|
||||
new(54.709792, 54.929312),
|
||||
new(54.929396, 55.258979),
|
||||
};
|
||||
|
||||
var range = new TimeRange(0, 60);
|
||||
|
@ -18,7 +18,7 @@ public class TestBlackFrames
|
||||
var range = 1e-5;
|
||||
|
||||
var expected = new List<BlackFrame>();
|
||||
expected.AddRange(CreateFrameSequence(2.04, 3));
|
||||
expected.AddRange(CreateFrameSequence(2, 3));
|
||||
expected.AddRange(CreateFrameSequence(5, 6));
|
||||
expected.AddRange(CreateFrameSequence(8, 9.96));
|
||||
|
||||
@ -43,7 +43,7 @@ public class TestBlackFrames
|
||||
var episode = QueueFile("credits.mp4");
|
||||
episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds;
|
||||
|
||||
var result = analyzer.AnalyzeMediaFile(episode, 240, 30, 85);
|
||||
var result = analyzer.AnalyzeMediaFile(episode, 240, 85);
|
||||
Assert.NotNull(result);
|
||||
Assert.InRange(result.Start, 300 - range, 300 + range);
|
||||
}
|
||||
|
@ -1,49 +0,0 @@
|
||||
// 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,7 +39,6 @@ Selenium is used to verify that the plugin's web interface works as expected. It
|
||||
* Changing settings (will be added in the future)
|
||||
* Maximum degree of parallelism
|
||||
* Selecting libraries for analysis
|
||||
* EDL settings
|
||||
* Introduction requirements
|
||||
* Auto skip
|
||||
* Show/hide skip prompt
|
||||
|
@ -1,119 +0,0 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using IntroSkipper.Configuration;
|
||||
using IntroSkipper.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace IntroSkipper;
|
||||
|
||||
/// <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,6 +5,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Configuration;
|
||||
using IntroSkipper.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -15,37 +16,14 @@ namespace IntroSkipper.Analyzers;
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFileAnalyzer
|
||||
{
|
||||
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
private readonly TimeSpan _maximumError = new(0, 0, 4);
|
||||
|
||||
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;
|
||||
}
|
||||
private readonly ILogger<BlackFrameAnalyzer> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles(
|
||||
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
||||
IReadOnlyList<QueuedEpisode> analysisQueue,
|
||||
AnalysisMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
@ -55,95 +33,41 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
throw new NotImplementedException("mode must equal Credits");
|
||||
}
|
||||
|
||||
var creditTimes = new Dictionary<Guid, Segment>();
|
||||
var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
|
||||
|
||||
var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue);
|
||||
var searchStart = 0.0;
|
||||
|
||||
bool isFirstEpisode = true;
|
||||
|
||||
double searchStart = _minimumCreditsDuration;
|
||||
|
||||
var searchDistance = 2 * _minimumCreditsDuration;
|
||||
|
||||
foreach (var episode in episodeAnalysisQueue.Where(e => !e.State.IsAnalyzed(mode)))
|
||||
foreach (var episode in episodesWithoutIntros)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration;
|
||||
|
||||
var chapters = Plugin.Instance!.GetChapters(episode.EpisodeId);
|
||||
var lastSuitableChapter = chapters.LastOrDefault(c =>
|
||||
if (!AnalyzeChapters(episode, out var credit))
|
||||
{
|
||||
var start = TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds;
|
||||
return start >= _minimumCreditsDuration && start <= creditDuration;
|
||||
});
|
||||
|
||||
if (lastSuitableChapter is not null)
|
||||
if (searchStart < _config.MinimumCreditsDuration)
|
||||
{
|
||||
searchStart = TimeSpan.FromTicks(lastSuitableChapter.StartPositionTicks).TotalSeconds;
|
||||
isFirstEpisode = false;
|
||||
searchStart = FindSearchStart(episode);
|
||||
}
|
||||
|
||||
if (isFirstEpisode)
|
||||
{
|
||||
var scanTime = episode.Duration - searchStart;
|
||||
var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here.
|
||||
|
||||
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
|
||||
|
||||
while (frames.Length > 0) // While black frames are found increase searchStart
|
||||
{
|
||||
searchStart += searchDistance;
|
||||
|
||||
scanTime = episode.Duration - searchStart;
|
||||
tr = new TimeRange(scanTime - 0.5, scanTime);
|
||||
|
||||
frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
|
||||
|
||||
if (searchStart > creditDuration)
|
||||
{
|
||||
searchStart = creditDuration;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchStart == _minimumCreditsDuration) // Skip if no black frames were found
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
isFirstEpisode = false;
|
||||
}
|
||||
|
||||
var credit = AnalyzeMediaFile(
|
||||
credit = AnalyzeMediaFile(
|
||||
episode,
|
||||
searchStart,
|
||||
searchDistance,
|
||||
_blackFrameMinimumPercentage);
|
||||
_config.BlackFrameMinimumPercentage);
|
||||
}
|
||||
|
||||
if (credit is null || !credit.Valid)
|
||||
{
|
||||
// If no credits were found, reset the first-episode search logic for the next episode in the sequence.
|
||||
searchStart = _minimumCreditsDuration;
|
||||
isFirstEpisode = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
searchStart = episode.Duration - credit.Start + (0.5 * searchDistance);
|
||||
|
||||
creditTimes.Add(episode.EpisodeId, credit);
|
||||
episode.State.SetAnalyzed(mode, true);
|
||||
episode.IsAnalyzed = true;
|
||||
await Plugin.Instance!.UpdateTimestampAsync(credit, mode).ConfigureAwait(false);
|
||||
searchStart = episode.Duration - credit.Start + _config.MinimumCreditsDuration;
|
||||
}
|
||||
|
||||
var analyzerHelper = new AnalyzerHelper(_logger);
|
||||
creditTimes = analyzerHelper.AdjustIntroTimes(analysisQueue, creditTimes, mode);
|
||||
|
||||
Plugin.Instance!.UpdateTimestamps(creditTimes, mode);
|
||||
|
||||
return episodeAnalysisQueue;
|
||||
return analysisQueue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -151,20 +75,18 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
/// </summary>
|
||||
/// <param name="episode">Media file to analyze.</param>
|
||||
/// <param name="searchStart">Search Start Piont.</param>
|
||||
/// <param name="searchDistance">Search Distance.</param>
|
||||
/// <param name="minimum">Percentage of the frame that must be black.</param>
|
||||
/// <returns>Credits timestamp.</returns>
|
||||
public Segment? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int searchDistance, int minimum)
|
||||
public Segment? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int minimum)
|
||||
{
|
||||
// Start by analyzing the last N minutes of the file.
|
||||
var searchDistance = 2 * _config.MinimumCreditsDuration;
|
||||
var upperLimit = searchStart;
|
||||
var lowerLimit = Math.Max(searchStart - searchDistance, _minimumCreditsDuration);
|
||||
var lowerLimit = Math.Max(searchStart - searchDistance, _config.MinimumCreditsDuration);
|
||||
var start = TimeSpan.FromSeconds(upperLimit);
|
||||
var end = TimeSpan.FromSeconds(lowerLimit);
|
||||
var firstFrameTime = 0.0;
|
||||
|
||||
var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration;
|
||||
|
||||
// Continue bisecting the end of the file until the range that contains the first black
|
||||
// frame is smaller than the maximum permitted error.
|
||||
while (start - end > _maximumError)
|
||||
@ -197,7 +119,7 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
|
||||
if (midpoint - TimeSpan.FromSeconds(lowerLimit) < _maximumError)
|
||||
{
|
||||
lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), _minimumCreditsDuration);
|
||||
lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), _config.MinimumCreditsDuration);
|
||||
|
||||
// Reset end for a new search with the increased duration
|
||||
end = TimeSpan.FromSeconds(lowerLimit);
|
||||
@ -211,7 +133,7 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
|
||||
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError)
|
||||
{
|
||||
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), creditDuration);
|
||||
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), episode.Duration - episode.CreditsFingerprintStart);
|
||||
|
||||
// Reset start for a new search with the increased duration
|
||||
start = TimeSpan.FromSeconds(upperLimit);
|
||||
@ -226,4 +148,71 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
|
||||
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,6 +7,7 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Configuration;
|
||||
using IntroSkipper.Data;
|
||||
using MediaBrowser.Model.Entities;
|
||||
@ -23,29 +24,32 @@ namespace IntroSkipper.Analyzers;
|
||||
/// <param name="logger">Logger.</param>
|
||||
public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyzer
|
||||
{
|
||||
private ILogger<ChapterAnalyzer> _logger = logger;
|
||||
private readonly ILogger<ChapterAnalyzer> _logger = logger;
|
||||
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles(
|
||||
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
||||
IReadOnlyList<QueuedEpisode> analysisQueue,
|
||||
AnalysisMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var skippableRanges = new Dictionary<Guid, Segment>();
|
||||
|
||||
// Episode analysis queue.
|
||||
var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue);
|
||||
|
||||
var expression = mode == AnalysisMode.Introduction ?
|
||||
Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern :
|
||||
Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern;
|
||||
var expression = mode switch
|
||||
{
|
||||
AnalysisMode.Introduction => _config.ChapterAnalyzerIntroductionPattern,
|
||||
AnalysisMode.Credits => _config.ChapterAnalyzerEndCreditsPattern,
|
||||
AnalysisMode.Recap => _config.ChapterAnalyzerRecapPattern,
|
||||
AnalysisMode.Preview => _config.ChapterAnalyzerPreviewPattern,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), $"Unexpected analysis mode: {mode}")
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
return analysisQueue;
|
||||
}
|
||||
|
||||
foreach (var episode in episodeAnalysisQueue.Where(e => !e.State.IsAnalyzed(mode)))
|
||||
var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
|
||||
|
||||
foreach (var episode in episodesWithoutIntros)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@ -54,7 +58,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
|
||||
|
||||
var skipRange = FindMatchingChapter(
|
||||
episode,
|
||||
Plugin.Instance.GetChapters(episode.EpisodeId),
|
||||
Plugin.Instance!.GetChapters(episode.EpisodeId),
|
||||
expression,
|
||||
mode);
|
||||
|
||||
@ -63,13 +67,11 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
|
||||
continue;
|
||||
}
|
||||
|
||||
skippableRanges.Add(episode.EpisodeId, skipRange);
|
||||
episode.State.SetAnalyzed(mode, true);
|
||||
episode.IsAnalyzed = true;
|
||||
await Plugin.Instance!.UpdateTimestampAsync(skipRange, mode).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Plugin.Instance.UpdateTimestamps(skippableRanges, mode);
|
||||
|
||||
return episodeAnalysisQueue;
|
||||
return analysisQueue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -93,12 +95,11 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
|
||||
return null;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
var creditDuration = episode.IsMovie ? config.MaximumMovieCreditsDuration : config.MaximumCreditsDuration;
|
||||
var reversed = mode != AnalysisMode.Introduction;
|
||||
var creditDuration = episode.IsMovie ? _config.MaximumMovieCreditsDuration : _config.MaximumCreditsDuration;
|
||||
var reversed = mode == AnalysisMode.Credits;
|
||||
var (minDuration, maxDuration) = reversed
|
||||
? (config.MinimumCreditsDuration, creditDuration)
|
||||
: (config.MinimumIntroDuration, config.MaximumIntroDuration);
|
||||
? (_config.MinimumCreditsDuration, creditDuration)
|
||||
: (_config.MinimumIntroDuration, _config.MaximumIntroDuration);
|
||||
|
||||
// Check all chapters
|
||||
for (int i = reversed ? count - 1 : 0; reversed ? i >= 0 : i < count; i += reversed ? -1 : 1)
|
||||
@ -135,7 +136,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
|
||||
var match = Regex.IsMatch(
|
||||
chapter.Name,
|
||||
expression,
|
||||
RegexOptions.None,
|
||||
RegexOptions.IgnoreCase,
|
||||
TimeSpan.FromSeconds(1));
|
||||
|
||||
if (!match)
|
||||
|
@ -3,10 +3,10 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Configuration;
|
||||
using IntroSkipper.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -14,85 +14,45 @@ using Microsoft.Extensions.Logging;
|
||||
namespace IntroSkipper.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// Chromaprint audio analyzer.
|
||||
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
|
||||
/// </summary>
|
||||
public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
/// <param name="logger">Logger.</param>
|
||||
public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFileAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Seconds of audio in one fingerprint point.
|
||||
/// This value is defined by the Chromaprint library and should not be changed.
|
||||
/// </summary>
|
||||
private const double SamplesToSeconds = 0.1238;
|
||||
|
||||
private readonly int _minimumIntroDuration;
|
||||
|
||||
private readonly int _maximumDifferences;
|
||||
|
||||
private readonly int _invertedIndexShift;
|
||||
|
||||
private readonly double _maximumTimeSkip;
|
||||
|
||||
private readonly ILogger<ChromaprintAnalyzer> _logger;
|
||||
|
||||
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
private readonly ILogger<ChromaprintAnalyzer> _logger = logger;
|
||||
private readonly Dictionary<Guid, Dictionary<uint, int>> _invertedIndexCache = [];
|
||||
private AnalysisMode _analysisMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger)
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
_maximumDifferences = config.MaximumFingerprintPointDifferences;
|
||||
_invertedIndexShift = config.InvertedIndexShift;
|
||||
_maximumTimeSkip = config.MaximumTimeSkip;
|
||||
_minimumIntroDuration = config.MinimumIntroDuration;
|
||||
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles(
|
||||
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
||||
IReadOnlyList<QueuedEpisode> analysisQueue,
|
||||
AnalysisMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Episodes that were not analyzed.
|
||||
var episodeAnalysisQueue = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
|
||||
|
||||
if (episodeAnalysisQueue.Count <= 1)
|
||||
{
|
||||
return analysisQueue;
|
||||
}
|
||||
|
||||
_analysisMode = mode;
|
||||
|
||||
// All intros for this season.
|
||||
var seasonIntros = new Dictionary<Guid, Segment>();
|
||||
|
||||
// Cache of all fingerprints for this season.
|
||||
var fingerprintCache = new Dictionary<Guid, uint[]>();
|
||||
|
||||
// Episode analysis queue based on not analyzed episodes
|
||||
var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue);
|
||||
|
||||
// Episodes that were analyzed and do not have an introduction.
|
||||
var episodesWithoutIntros = episodeAnalysisQueue.Where(e => !e.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
|
||||
foreach (var episode in episodesWithFingerprint)
|
||||
foreach (var episode in episodeAnalysisQueue)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -120,15 +80,14 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
}
|
||||
|
||||
// While there are still episodes in the queue
|
||||
while (episodesWithoutIntros.Count > 0)
|
||||
while (episodeAnalysisQueue.Count > 0)
|
||||
{
|
||||
// Pop the first episode from the queue
|
||||
var currentEpisode = episodesWithoutIntros[0];
|
||||
episodesWithoutIntros.RemoveAt(0);
|
||||
episodesWithFingerprint.Remove(currentEpisode);
|
||||
var currentEpisode = episodeAnalysisQueue[0];
|
||||
episodeAnalysisQueue.RemoveAt(0);
|
||||
|
||||
// Search through all remaining episodes.
|
||||
foreach (var remainingEpisode in episodesWithFingerprint)
|
||||
foreach (var remainingEpisode in episodeAnalysisQueue)
|
||||
{
|
||||
// Compare the current episode to all remaining episodes in the queue.
|
||||
var (currentIntro, remainingIntro) = CompareEpisodes(
|
||||
@ -189,29 +148,17 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
break;
|
||||
}
|
||||
|
||||
// If no intro is found at this point, the popped episode is not reinserted into the queue.
|
||||
if (seasonIntros.ContainsKey(currentEpisode.EpisodeId))
|
||||
// If an intro is found for this episode, adjust its times and save it else add it to the list of episodes without intros.
|
||||
if (seasonIntros.TryGetValue(currentEpisode.EpisodeId, out var intro))
|
||||
{
|
||||
episodesWithFingerprint.Add(currentEpisode);
|
||||
episodeAnalysisQueue.FirstOrDefault(x => x.EpisodeId == currentEpisode.EpisodeId)?.State.SetAnalyzed(mode, true);
|
||||
currentEpisode.IsAnalyzed = true;
|
||||
await Plugin.Instance!.UpdateTimestampAsync(intro, mode).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// If cancellation was requested, report that no episodes were analyzed.
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return analysisQueue;
|
||||
}
|
||||
|
||||
// Adjust all introduction times.
|
||||
var analyzerHelper = new AnalyzerHelper(_logger);
|
||||
seasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, seasonIntros, _analysisMode);
|
||||
|
||||
Plugin.Instance!.UpdateTimestamps(seasonIntros, _analysisMode);
|
||||
|
||||
return episodeAnalysisQueue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyze two episodes to find an introduction sequence shared between them.
|
||||
/// </summary>
|
||||
@ -299,8 +246,8 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
var rhsRanges = new List<TimeRange>();
|
||||
|
||||
// Generate inverted indexes for the left and right episodes.
|
||||
var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, _analysisMode);
|
||||
var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, _analysisMode);
|
||||
var lhsIndex = CreateInvertedIndex(lhsId, lhsPoints);
|
||||
var rhsIndex = CreateInvertedIndex(rhsId, rhsPoints);
|
||||
var indexShifts = new HashSet<int>();
|
||||
|
||||
// For all audio points in the left episode, check if the right episode has a point which matches exactly.
|
||||
@ -309,7 +256,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
{
|
||||
var originalPoint = kvp.Key;
|
||||
|
||||
for (var i = -1 * _invertedIndexShift; i <= _invertedIndexShift; i++)
|
||||
for (var i = -1 * _config.InvertedIndexShift; i <= _config.InvertedIndexShift; i++)
|
||||
{
|
||||
var modifiedPoint = (uint)(originalPoint + i);
|
||||
|
||||
@ -374,7 +321,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
var diff = lhs[lhsPosition] ^ rhs[rhsPosition];
|
||||
|
||||
// If the difference between the samples is small, flag both times as similar.
|
||||
if (CountBits(diff) > _maximumDifferences)
|
||||
if (CountBits(diff) > _config.MaximumFingerprintPointDifferences)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@ -391,23 +338,156 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
rhsTimes.Add(double.MaxValue);
|
||||
|
||||
// Now that both fingerprints have been compared at this shift, see if there's a contiguous time range.
|
||||
var lContiguous = TimeRangeHelpers.FindContiguous(lhsTimes.ToArray(), _maximumTimeSkip);
|
||||
if (lContiguous is null || lContiguous.Duration < _minimumIntroDuration)
|
||||
var lContiguous = TimeRangeHelpers.FindContiguous([.. lhsTimes], _config.MaximumTimeSkip);
|
||||
if (lContiguous is null || lContiguous.Duration < _config.MinimumIntroDuration)
|
||||
{
|
||||
return (new TimeRange(), new TimeRange());
|
||||
}
|
||||
|
||||
// Since LHS had a contiguous time range, RHS must have one also.
|
||||
var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), _maximumTimeSkip)!;
|
||||
var rContiguous = TimeRangeHelpers.FindContiguous([.. rhsTimes], _config.MaximumTimeSkip)!;
|
||||
return (lContiguous, rContiguous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjusts the end timestamps of all intros so that they end at silence.
|
||||
/// </summary>
|
||||
/// <param name="episode">QueuedEpisode to adjust.</param>
|
||||
/// <param name="originalIntro">Original introduction.</param>
|
||||
private Segment AdjustIntroTimes(
|
||||
QueuedEpisode episode,
|
||||
Segment originalIntro)
|
||||
{
|
||||
_logger.LogTrace(
|
||||
"{Name} original intro: {Start} - {End}",
|
||||
episode.Name,
|
||||
originalIntro.Start,
|
||||
originalIntro.End);
|
||||
|
||||
var originalIntroStart = new TimeRange(
|
||||
Math.Max(0, (int)originalIntro.Start - 5),
|
||||
(int)originalIntro.Start + 10);
|
||||
|
||||
var originalIntroEnd = new TimeRange(
|
||||
(int)originalIntro.End - 10,
|
||||
Math.Min(episode.Duration, (int)originalIntro.End + 5));
|
||||
|
||||
// Try to adjust based on chapters first, fall back to silence detection for intros
|
||||
if (!AdjustIntroBasedOnChapters(episode, originalIntro, originalIntroStart, originalIntroEnd) &&
|
||||
_analysisMode == AnalysisMode.Introduction)
|
||||
{
|
||||
AdjustIntroBasedOnSilence(episode, originalIntro, originalIntroEnd);
|
||||
}
|
||||
|
||||
_logger.LogTrace(
|
||||
"{Name} adjusted intro: {Start} - {End}",
|
||||
episode.Name,
|
||||
originalIntro.Start,
|
||||
originalIntro.End);
|
||||
|
||||
return originalIntro;
|
||||
}
|
||||
|
||||
private bool AdjustIntroBasedOnChapters(
|
||||
QueuedEpisode episode,
|
||||
Segment intro,
|
||||
TimeRange originalIntroStart,
|
||||
TimeRange originalIntroEnd)
|
||||
{
|
||||
var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? [];
|
||||
double previousTime = 0;
|
||||
|
||||
for (int i = 0; i <= chapters.Count; i++)
|
||||
{
|
||||
double currentTime = i < chapters.Count
|
||||
? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds
|
||||
: episode.Duration;
|
||||
|
||||
if (IsTimeWithinRange(previousTime, originalIntroStart))
|
||||
{
|
||||
intro.Start = previousTime;
|
||||
_logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime);
|
||||
}
|
||||
|
||||
if (IsTimeWithinRange(currentTime, originalIntroEnd))
|
||||
{
|
||||
intro.End = currentTime;
|
||||
_logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
previousTime = currentTime;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment intro, TimeRange originalIntroEnd)
|
||||
{
|
||||
var silenceRanges = FFmpegWrapper.DetectSilence(episode, originalIntroEnd);
|
||||
|
||||
foreach (var silenceRange in silenceRanges)
|
||||
{
|
||||
_logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, silenceRange.Start, silenceRange.End);
|
||||
|
||||
if (IsValidSilenceForIntroAdjustment(silenceRange, originalIntroEnd, intro))
|
||||
{
|
||||
intro.End = silenceRange.Start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsValidSilenceForIntroAdjustment(
|
||||
TimeRange silenceRange,
|
||||
TimeRange originalIntroEnd,
|
||||
Segment adjustedIntro)
|
||||
{
|
||||
return originalIntroEnd.Intersects(silenceRange) &&
|
||||
silenceRange.Duration >= _config.SilenceDetectionMinimumDuration &&
|
||||
silenceRange.Start >= adjustedIntro.Start;
|
||||
}
|
||||
|
||||
private static bool IsTimeWithinRange(double time, TimeRange range)
|
||||
{
|
||||
return range.Start < time && time < range.End;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at.
|
||||
/// </summary>
|
||||
/// <param name="id">Episode ID.</param>
|
||||
/// <param name="fingerprint">Chromaprint fingerprint.</param>
|
||||
/// <returns>Inverted index.</returns>
|
||||
public Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint)
|
||||
{
|
||||
if (_invertedIndexCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var invIndex = new Dictionary<uint, int>();
|
||||
|
||||
for (int i = 0; i < fingerprint.Length; i++)
|
||||
{
|
||||
// Get the current point.
|
||||
var point = fingerprint[i];
|
||||
|
||||
// Append the current sample's timecode to the collection for this point.
|
||||
invIndex[point] = i;
|
||||
}
|
||||
|
||||
_invertedIndexCache[id] = invIndex;
|
||||
|
||||
return invIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Count the number of bits that are set in the provided number.
|
||||
/// </summary>
|
||||
/// <param name="number">Number to count bits in.</param>
|
||||
/// <returns>Number of bits that are equal to 1.</returns>
|
||||
public int CountBits(uint number)
|
||||
public static int CountBits(uint number)
|
||||
{
|
||||
return BitOperations.PopCount(number);
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Data;
|
||||
|
||||
namespace IntroSkipper.Analyzers;
|
||||
@ -19,7 +20,7 @@ public interface IMediaFileAnalyzer
|
||||
/// <param name="mode">Analysis mode.</param>
|
||||
/// <param name="cancellationToken">Cancellation token from scheduled task.</param>
|
||||
/// <returns>Collection of media files that were **unsuccessfully analyzed**.</returns>
|
||||
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles(
|
||||
Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
||||
IReadOnlyList<QueuedEpisode> analysisQueue,
|
||||
AnalysisMode mode,
|
||||
CancellationToken cancellationToken);
|
||||
|
@ -1,35 +0,0 @@
|
||||
// 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,6 +1,7 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using IntroSkipper.Data;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
@ -39,17 +40,12 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// <summary>
|
||||
/// Gets or sets the list of client to auto skip for.
|
||||
/// </summary>
|
||||
public string ClientList { get; set; } = "Kodi";
|
||||
public string ClientList { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to scan for intros during a scheduled task.
|
||||
/// Gets or sets a value indicating whether to automatically scan newly added items.
|
||||
/// </summary>
|
||||
public bool AutoDetectIntros { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to scan for credits during a scheduled task.
|
||||
/// </summary>
|
||||
public bool AutoDetectCredits { get; set; }
|
||||
public bool AutoDetectIntros { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to analyze season 0.
|
||||
@ -66,22 +62,42 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
public bool WithChromaprint { get; set; } = true;
|
||||
|
||||
// ===== EDL handling =====
|
||||
// ===== Media Segment handling =====
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating the action to write to created EDL files.
|
||||
/// Gets or sets a value indicating whether to update Media Segments.
|
||||
/// </summary>
|
||||
public EdlAction EdlAction { get; set; } = EdlAction.None;
|
||||
public bool UpdateMediaSegments { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to regenerate all EDL files during the next scan.
|
||||
/// 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 EDL files will be regenerated and overwrite any existing EDL file.
|
||||
/// Gets or sets a value indicating whether to regenerate all Media Segments during the next scan.
|
||||
/// By default, Media Segments 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.
|
||||
/// </summary>
|
||||
public bool RegenerateEdlFiles { get; set; }
|
||||
public bool RebuildMediaSegments { get; set; } = true;
|
||||
|
||||
// ===== Custom analysis settings =====
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether Introductions should be analyzed.
|
||||
/// </summary>
|
||||
public bool ScanIntroduction { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether Credits should be analyzed.
|
||||
/// </summary>
|
||||
public bool ScanCredits { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether Recaps should be analyzed.
|
||||
/// </summary>
|
||||
public bool ScanRecap { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether Previews should be analyzed.
|
||||
/// </summary>
|
||||
public bool ScanPreview { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the percentage of each episode's audio track to analyze.
|
||||
/// </summary>
|
||||
@ -126,36 +142,68 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// Gets or sets the regular expression used to detect introduction chapters.
|
||||
/// </summary>
|
||||
public string ChapterAnalyzerIntroductionPattern { get; set; } =
|
||||
@"(^|\s)(Intro|Introduction|OP|Opening)(\s|$)";
|
||||
@"(^|\s)(Intro|Introduction|OP|Opening)(?!\sEnd)(\s|$)";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the regular expression used to detect ending credit chapters.
|
||||
/// </summary>
|
||||
public string ChapterAnalyzerEndCreditsPattern { get; set; } =
|
||||
@"(^|\s)(Credits?|ED|Ending|End|Outro)(\s|$)";
|
||||
@"(^|\s)(Credits?|ED|Ending|Outro)(?!\sEnd)(\s|$)";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the regular expression used to detect Preview chapters.
|
||||
/// </summary>
|
||||
public string ChapterAnalyzerPreviewPattern { get; set; } =
|
||||
@"(^|\s)(Preview|PV|Sneak\s?Peek|Coming\s?(Up|Soon)|Next\s+(time|on|episode)|Extra|Teaser|Trailer)(?!\sEnd)(\s|:|$)";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the regular expression used to detect Recap chapters.
|
||||
/// </summary>
|
||||
public string ChapterAnalyzerRecapPattern { get; set; } =
|
||||
@"(^|\s)(Re?cap|Sum{1,2}ary|Prev(ious(ly)?)?|(Last|Earlier)(\s\w+)?|Catch[ -]up)(?!\sEnd)(\s|:|$)";
|
||||
|
||||
// ===== Playback settings =====
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to show the skip intro button.
|
||||
/// </summary>
|
||||
public bool SkipButtonVisible { get; set; } = true;
|
||||
public bool SkipButtonEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to show the skip intro warning.
|
||||
/// </summary>
|
||||
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>
|
||||
/// Gets or sets a value indicating whether introductions should be automatically skipped.
|
||||
/// </summary>
|
||||
public bool AutoSkip { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of segment types to auto skip.
|
||||
/// </summary>
|
||||
public string TypeList { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether credits should be automatically skipped.
|
||||
/// </summary>
|
||||
public bool AutoSkipCredits { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether recap should be automatically skipped.
|
||||
/// </summary>
|
||||
public bool AutoSkipRecap { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether preview should be automatically skipped.
|
||||
/// </summary>
|
||||
public bool AutoSkipPreview { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the seconds before the intro starts to show the skip prompt at.
|
||||
/// </summary>
|
||||
@ -186,11 +234,6 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
public int SecondsOfIntroStartToPlay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the amount of credit at start to play (in seconds).
|
||||
/// </summary>
|
||||
public int SecondsOfCreditsStartToPlay { get; set; }
|
||||
|
||||
// ===== Internal algorithm settings =====
|
||||
|
||||
/// <summary>
|
||||
@ -235,12 +278,7 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// <summary>
|
||||
/// Gets or sets the notification text sent after automatically skipping an introduction.
|
||||
/// </summary>
|
||||
public string AutoSkipNotificationText { get; set; } = "Intro skipped";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification text sent after automatically skipping credits.
|
||||
/// </summary>
|
||||
public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
|
||||
public string AutoSkipNotificationText { get; set; } = "Segment skipped";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the max degree of parallelism used when analyzing episodes.
|
||||
|
@ -14,8 +14,10 @@ namespace IntroSkipper.Configuration;
|
||||
/// <param name="creditsText">Skip button end credits text.</param>
|
||||
/// <param name="autoSkip">Auto Skip Intro.</param>
|
||||
/// <param name="autoSkipCredits">Auto Skip Credits.</param>
|
||||
/// <param name="autoSkipRecap">Auto Skip Recap.</param>
|
||||
/// <param name="autoSkipPreview">Auto Skip Preview.</param>
|
||||
/// <param name="clientList">Auto Skip Clients.</param>
|
||||
public class UserInterfaceConfiguration(bool visible, string introText, string creditsText, bool autoSkip, bool autoSkipCredits, string clientList)
|
||||
public class UserInterfaceConfiguration(bool visible, string introText, string creditsText, bool autoSkip, bool autoSkipCredits, bool autoSkipRecap, bool autoSkipPreview, string clientList)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to show the skip intro button.
|
||||
@ -42,6 +44,16 @@ public class UserInterfaceConfiguration(bool visible, string introText, string c
|
||||
/// </summary>
|
||||
public bool AutoSkipCredits { get; set; } = autoSkipCredits;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether auto skip recap.
|
||||
/// </summary>
|
||||
public bool AutoSkipRecap { get; set; } = autoSkipRecap;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether auto skip preview.
|
||||
/// </summary>
|
||||
public bool AutoSkipPreview { get; set; } = autoSkipPreview;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating clients to auto skip for.
|
||||
/// </summary>
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
||||
const introSkipper = {
|
||||
originalFetch: window.fetch.bind(window),
|
||||
originalXHROpen: XMLHttpRequest.prototype.open,
|
||||
d: (msg) => console.debug("[intro skipper] ", msg),
|
||||
setup() {
|
||||
const self = this;
|
||||
@ -8,6 +9,9 @@ const introSkipper = {
|
||||
this.currentOption =
|
||||
localStorage.getItem("introskipperOption") || "Show Button";
|
||||
window.fetch = this.fetchWrapper.bind(this);
|
||||
XMLHttpRequest.prototype.open = function (...args) {
|
||||
self.xhrOpenWrapper(this, ...args);
|
||||
};
|
||||
document.addEventListener("viewshow", this.viewShow.bind(this));
|
||||
this.videoPositionChanged = this.videoPositionChanged.bind(this);
|
||||
this.handleEscapeKey = this.handleEscapeKey.bind(this);
|
||||
@ -39,14 +43,15 @@ const introSkipper = {
|
||||
fetchWrapper(resource, options) {
|
||||
const response = this.originalFetch(resource, options);
|
||||
const url = new URL(resource);
|
||||
if (url.pathname.includes("/PlaybackInfo")) {
|
||||
this.processPlaybackInfo(url.pathname);
|
||||
}
|
||||
else if (this.injectMetadata && url.pathname.includes("/MetadataEditor")) {
|
||||
if (this.injectMetadata && url.pathname.includes("/MetadataEditor")) {
|
||||
this.processMetadata(url.pathname);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
xhrOpenWrapper(xhr, method, url, ...rest) {
|
||||
url.includes("/PlaybackInfo") && this.processPlaybackInfo(url);
|
||||
return this.originalXHROpen.apply(xhr, [method, url, ...rest]);
|
||||
},
|
||||
async processPlaybackInfo(url) {
|
||||
const id = this.extractId(url);
|
||||
if (id) {
|
||||
@ -228,7 +233,8 @@ const introSkipper = {
|
||||
position > segment.IntroStart &&
|
||||
position < segment.IntroEnd - 3)
|
||||
) {
|
||||
return { ...segment, SegmentType: key };
|
||||
segment["SegmentType"] = key;
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
return { SegmentType: "None" };
|
||||
|
@ -3,14 +3,20 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Configuration;
|
||||
using IntroSkipper.Data;
|
||||
using IntroSkipper.Db;
|
||||
using IntroSkipper.Manager;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IntroSkipper.Controllers;
|
||||
|
||||
@ -20,14 +26,9 @@ namespace IntroSkipper.Controllers;
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class SkipIntroController : ControllerBase
|
||||
public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateManager) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SkipIntroController"/> class.
|
||||
/// </summary>
|
||||
public SkipIntroController()
|
||||
{
|
||||
}
|
||||
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format.
|
||||
@ -43,9 +44,8 @@ public class SkipIntroController : ControllerBase
|
||||
[FromRoute] Guid id,
|
||||
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
|
||||
{
|
||||
var intro = GetIntro(id, mode);
|
||||
|
||||
if (intro is null || !intro.Valid)
|
||||
var intros = GetIntros(id);
|
||||
if (!intros.TryGetValue(mode, out var intro))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
@ -58,34 +58,52 @@ public class SkipIntroController : ControllerBase
|
||||
/// </summary>
|
||||
/// <param name="id">Episode ID to update timestamps for.</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="404">Given ID is not an Episode.</response>
|
||||
/// <returns>No content.</returns>
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[HttpPost("Episode/{Id}/Timestamps")]
|
||||
public ActionResult UpdateTimestamps([FromRoute] Guid id, [FromBody] TimeStamps timestamps)
|
||||
public async Task<ActionResult> UpdateTimestampsAsync([FromRoute] Guid id, [FromBody] TimeStamps timestamps, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// only update existing episodes
|
||||
var rawItem = Plugin.Instance!.GetItem(id);
|
||||
if (rawItem == null || rawItem is not Episode and not Movie)
|
||||
if (rawItem is not Episode and not Movie)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (timestamps?.Introduction.End > 0.0)
|
||||
if (timestamps == null)
|
||||
{
|
||||
var tr = new TimeRange(timestamps.Introduction.Start, timestamps.Introduction.End);
|
||||
Plugin.Instance!.Intros[id] = new Segment(id, tr);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
if (timestamps?.Credits.End > 0.0)
|
||||
var segmentTypes = new[]
|
||||
{
|
||||
var cr = new TimeRange(timestamps.Credits.Start, timestamps.Credits.End);
|
||||
Plugin.Instance!.Credits[id] = new Segment(id, cr);
|
||||
(AnalysisMode.Introduction, timestamps.Introduction),
|
||||
(AnalysisMode.Credits, timestamps.Credits),
|
||||
(AnalysisMode.Recap, timestamps.Recap),
|
||||
(AnalysisMode.Preview, timestamps.Preview)
|
||||
};
|
||||
|
||||
foreach (var (mode, segment) in segmentTypes)
|
||||
{
|
||||
if (segment.Valid)
|
||||
{
|
||||
await Plugin.Instance!.UpdateTimestampAsync(segment, mode).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction);
|
||||
Plugin.Instance!.SaveTimestamps(AnalysisMode.Credits);
|
||||
if (Plugin.Instance.Configuration.UpdateMediaSegments)
|
||||
{
|
||||
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();
|
||||
}
|
||||
@ -103,20 +121,32 @@ public class SkipIntroController : ControllerBase
|
||||
{
|
||||
// only get return content for episodes
|
||||
var rawItem = Plugin.Instance!.GetItem(id);
|
||||
if (rawItem == null || rawItem is not Episode and not Movie)
|
||||
if (rawItem is not Episode and not Movie)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var times = new TimeStamps();
|
||||
if (Plugin.Instance!.Intros.TryGetValue(id, out var introValue))
|
||||
var segments = Plugin.Instance!.GetTimestamps(id);
|
||||
|
||||
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
|
||||
{
|
||||
times.Introduction = introValue;
|
||||
times.Introduction = introSegment;
|
||||
}
|
||||
|
||||
if (Plugin.Instance!.Credits.TryGetValue(id, out var creditValue))
|
||||
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment))
|
||||
{
|
||||
times.Credits = creditValue;
|
||||
times.Credits = creditSegment;
|
||||
}
|
||||
|
||||
if (segments.TryGetValue(AnalysisMode.Recap, out var recapSegment))
|
||||
{
|
||||
times.Recap = recapSegment;
|
||||
}
|
||||
|
||||
if (segments.TryGetValue(AnalysisMode.Preview, out var previewSegment))
|
||||
{
|
||||
times.Preview = previewSegment;
|
||||
}
|
||||
|
||||
return times;
|
||||
@ -131,66 +161,66 @@ public class SkipIntroController : ControllerBase
|
||||
[HttpGet("Episode/{id}/IntroSkipperSegments")]
|
||||
public ActionResult<Dictionary<AnalysisMode, Intro>> GetSkippableSegments([FromRoute] Guid id)
|
||||
{
|
||||
var segments = new Dictionary<AnalysisMode, Intro>();
|
||||
var segments = GetIntros(id);
|
||||
var result = new Dictionary<AnalysisMode, Intro>();
|
||||
|
||||
if (GetIntro(id, AnalysisMode.Introduction) is Intro intro)
|
||||
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
|
||||
{
|
||||
segments[AnalysisMode.Introduction] = intro;
|
||||
result[AnalysisMode.Introduction] = introSegment;
|
||||
}
|
||||
|
||||
if (GetIntro(id, AnalysisMode.Credits) is Intro credits)
|
||||
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment))
|
||||
{
|
||||
segments[AnalysisMode.Credits] = credits;
|
||||
result[AnalysisMode.Credits] = creditSegment;
|
||||
}
|
||||
|
||||
return segments;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Lookup and return the skippable timestamps for the provided item.</summary>
|
||||
/// <param name="id">Unique identifier of this episode.</param>
|
||||
/// <param name="mode">Mode.</param>
|
||||
/// <returns>Intro object if the provided item has an intro, null otherwise.</returns>
|
||||
private static Intro? GetIntro(Guid id, AnalysisMode mode)
|
||||
internal static Dictionary<AnalysisMode, Intro> GetIntros(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var timestamp = Plugin.GetIntroByMode(id, mode);
|
||||
var timestamps = Plugin.Instance!.GetTimestamps(id);
|
||||
var intros = new Dictionary<AnalysisMode, Intro>();
|
||||
var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds;
|
||||
var config = Plugin.Instance.Configuration;
|
||||
|
||||
// Operate on a copy to avoid mutating the original Intro object stored in the dictionary.
|
||||
foreach (var (mode, timestamp) in timestamps)
|
||||
{
|
||||
if (!timestamp.Valid)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create new Intro to avoid mutating the original stored in dictionary
|
||||
var segment = new Intro(timestamp);
|
||||
|
||||
var config = Plugin.Instance!.Configuration;
|
||||
segment.IntroEnd = mode == AnalysisMode.Credits
|
||||
? GetAdjustedIntroEnd(id, segment.IntroEnd, config)
|
||||
// Calculate intro end time
|
||||
segment.IntroEnd = runTime > 0 && runTime < segment.IntroEnd + 1
|
||||
? runTime
|
||||
: segment.IntroEnd - config.RemainingSecondsOfIntro;
|
||||
|
||||
// Set skip button prompt visibility times
|
||||
const double MIN_REMAINING_TIME = 3.0; // Minimum seconds before end to hide prompt
|
||||
if (config.PersistSkipButton)
|
||||
{
|
||||
segment.ShowSkipPromptAt = segment.IntroStart;
|
||||
segment.HideSkipPromptAt = segment.IntroEnd - 3;
|
||||
segment.HideSkipPromptAt = segment.IntroEnd - MIN_REMAINING_TIME;
|
||||
}
|
||||
else
|
||||
{
|
||||
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
|
||||
segment.HideSkipPromptAt = Math.Min(
|
||||
segment.IntroStart + config.HidePromptAdjustment,
|
||||
segment.IntroEnd - 3);
|
||||
segment.IntroEnd - MIN_REMAINING_TIME);
|
||||
}
|
||||
|
||||
return segment;
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
intros[mode] = segment;
|
||||
}
|
||||
|
||||
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;
|
||||
return intros;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -202,24 +232,22 @@ public class SkipIntroController : ControllerBase
|
||||
/// <returns>No content.</returns>
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[HttpPost("Intros/EraseTimestamps")]
|
||||
public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false)
|
||||
public async Task<ActionResult> ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false)
|
||||
{
|
||||
if (mode == AnalysisMode.Introduction)
|
||||
using var db = new IntroSkipperDbContext(Plugin.Instance!.DbPath);
|
||||
var segments = await db.DbSegment
|
||||
.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)
|
||||
{
|
||||
Plugin.Instance!.Intros.Clear();
|
||||
}
|
||||
else if (mode == AnalysisMode.Credits)
|
||||
{
|
||||
Plugin.Instance!.Credits.Clear();
|
||||
await Task.Run(() => FFmpegWrapper.DeleteCacheFiles(mode)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (eraseCache)
|
||||
{
|
||||
FFmpegWrapper.DeleteCacheFiles(mode);
|
||||
}
|
||||
|
||||
Plugin.Instance!.EpisodeStates.Clear();
|
||||
Plugin.Instance!.SaveTimestamps(mode);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -234,11 +262,13 @@ public class SkipIntroController : ControllerBase
|
||||
{
|
||||
var config = Plugin.Instance!.Configuration;
|
||||
return new UserInterfaceConfiguration(
|
||||
config.SkipButtonVisible,
|
||||
config.SkipButtonEnabled,
|
||||
config.SkipButtonIntroText,
|
||||
config.SkipButtonEndCreditsText,
|
||||
config.AutoSkip,
|
||||
config.AutoSkipCredits,
|
||||
config.AutoSkipRecap,
|
||||
config.AutoSkipPreview,
|
||||
config.ClientList);
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,11 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Data;
|
||||
using IntroSkipper.Db;
|
||||
using IntroSkipper.Manager;
|
||||
using MediaBrowser.Common.Api;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -21,13 +25,15 @@ namespace IntroSkipper.Controllers;
|
||||
/// Initializes a new instance of the <see cref="VisualizationController"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="mediaSegmentUpdateManager">Media Segment Update Manager.</param>
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ApiController]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
[Route("Intros")]
|
||||
public class VisualizationController(ILogger<VisualizationController> logger) : ControllerBase
|
||||
public class VisualizationController(ILogger<VisualizationController> logger, MediaSegmentUpdateManager mediaSegmentUpdateManager) : ControllerBase
|
||||
{
|
||||
private readonly ILogger<VisualizationController> _logger = logger;
|
||||
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
||||
|
||||
/// <summary>
|
||||
/// Returns all show names and seasons.
|
||||
@ -78,49 +84,25 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ignore list for the provided season.
|
||||
/// Returns the analyzer actions for the provided season.
|
||||
/// </summary>
|
||||
/// <param name="seasonId">Season ID.</param>
|
||||
/// <returns>List of episode titles.</returns>
|
||||
[HttpGet("IgnoreListSeason/{SeasonId}")]
|
||||
public ActionResult<IgnoreListItem> GetIgnoreListSeason([FromRoute] Guid seasonId)
|
||||
[HttpGet("AnalyzerActions/{SeasonId}")]
|
||||
public ActionResult<IReadOnlyDictionary<AnalysisMode, AnalyzerAction>> GetAnalyzerAction([FromRoute] Guid seasonId)
|
||||
{
|
||||
if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(seasonId))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!Plugin.Instance!.IgnoreList.TryGetValue(seasonId, out _))
|
||||
var analyzerActions = new Dictionary<AnalysisMode, AnalyzerAction>();
|
||||
foreach (var mode in Enum.GetValues<AnalysisMode>())
|
||||
{
|
||||
return new IgnoreListItem(seasonId);
|
||||
analyzerActions[mode] = Plugin.Instance!.GetAnalyzerAction(seasonId, mode);
|
||||
}
|
||||
|
||||
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))
|
||||
};
|
||||
return Ok(analyzerActions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -176,16 +158,14 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
|
||||
/// <param name="seriesId">Show ID.</param>
|
||||
/// <param name="seasonId">Season ID.</param>
|
||||
/// <param name="eraseCache">Erase cache.</param>
|
||||
/// <param name="cancellationToken">Cancellation Token.</param>
|
||||
/// <response code="204">Season timestamps erased.</response>
|
||||
/// <response code="404">Unable to find season in provided series.</response>
|
||||
/// <returns>No content.</returns>
|
||||
[HttpDelete("Show/{SeriesId}/{SeasonId}")]
|
||||
public ActionResult EraseSeason([FromRoute] Guid seriesId, [FromRoute] Guid seasonId, [FromQuery] bool eraseCache = false)
|
||||
public async Task<ActionResult> EraseSeasonAsync([FromRoute] Guid seriesId, [FromRoute] Guid seasonId, [FromQuery] bool eraseCache = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var episodes = Plugin.Instance!.QueuedMediaItems
|
||||
.Where(kvp => kvp.Key == seasonId)
|
||||
.SelectMany(kvp => kvp.Value.Where(e => e.SeriesId == seriesId))
|
||||
.ToList();
|
||||
var episodes = Plugin.Instance!.QueuedMediaItems[seasonId];
|
||||
|
||||
if (episodes.Count == 0)
|
||||
{
|
||||
@ -194,99 +174,55 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
|
||||
|
||||
_logger.LogInformation("Erasing timestamps for series {SeriesId} season {SeasonId} at user request", seriesId, seasonId);
|
||||
|
||||
foreach (var e in episodes)
|
||||
try
|
||||
{
|
||||
Plugin.Instance!.Intros.TryRemove(e.EpisodeId, out _);
|
||||
Plugin.Instance!.Credits.TryRemove(e.EpisodeId, out _);
|
||||
e.State.ResetStates();
|
||||
using var db = new IntroSkipperDbContext(Plugin.Instance!.DbPath);
|
||||
|
||||
foreach (var episode in episodes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var existingSegments = db.DbSegment.Where(s => s.ItemId == episode.EpisodeId);
|
||||
|
||||
db.DbSegment.RemoveRange(existingSegments);
|
||||
|
||||
if (eraseCache)
|
||||
{
|
||||
FFmpegWrapper.DeleteEpisodeCache(e.EpisodeId);
|
||||
await Task.Run(() => FFmpegWrapper.DeleteEpisodeCache(episode.EpisodeId), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction | AnalysisMode.Credits);
|
||||
var seasonInfo = db.DbSeasonInfo.Where(s => s.SeasonId == seasonId);
|
||||
|
||||
return NoContent();
|
||||
foreach (var info in seasonInfo)
|
||||
{
|
||||
db.Entry(info).Property(s => s.EpisodeIds).CurrentValue = [];
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
Plugin.Instance!.IgnoreList.AddOrUpdate(ignoreListItem.SeasonId, ignoreListItem, (_, _) => ignoreListItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
Plugin.Instance!.IgnoreList.TryRemove(ignoreListItem.SeasonId, out _);
|
||||
}
|
||||
|
||||
if (save)
|
||||
{
|
||||
Plugin.Instance!.SaveIgnoreList();
|
||||
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the ignore list for the provided series.
|
||||
/// </summary>
|
||||
/// <param name="seriesId">Series ID.</param>
|
||||
/// <param name="ignoreListItem">New ignore list items.</param>
|
||||
/// <returns>No content.</returns>
|
||||
[HttpPost("IgnoreList/UpdateSeries/{SeriesId}")]
|
||||
public ActionResult UpdateIgnoreListSeries([FromRoute] Guid seriesId, [FromBody] IgnoreListItem ignoreListItem)
|
||||
catch (Exception ex)
|
||||
{
|
||||
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 StatusCode(500, ex.Message);
|
||||
}
|
||||
|
||||
foreach (var seasonId in seasonIds)
|
||||
{
|
||||
UpdateIgnoreListSeason(new IgnoreListItem(ignoreListItem) { SeasonId = seasonId }, false);
|
||||
}
|
||||
|
||||
Plugin.Instance!.SaveIgnoreList();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the introduction timestamps for the provided episode.
|
||||
/// Updates the analyzer actions for the provided season.
|
||||
/// </summary>
|
||||
/// <param name="id">Episode ID to update timestamps for.</param>
|
||||
/// <param name="timestamps">New introduction start and end times.</param>
|
||||
/// <response code="204">New introduction timestamps saved.</response>
|
||||
/// <param name="request">Update analyzer actions request.</param>
|
||||
/// <returns>No content.</returns>
|
||||
[HttpPost("Episode/{Id}/UpdateIntroTimestamps")]
|
||||
[Obsolete("deprecated use Episode/{Id}/Timestamps")]
|
||||
public ActionResult UpdateIntroTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps)
|
||||
[HttpPost("AnalyzerActions/UpdateSeason")]
|
||||
public async Task<ActionResult> UpdateAnalyzerActions([FromBody] UpdateAnalyzerActionsRequest request)
|
||||
{
|
||||
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);
|
||||
}
|
||||
await Plugin.Instance!.SetAnalyzerActionAsync(request.Id, request.AnalyzerActions).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
@ -17,4 +17,14 @@ public enum AnalysisMode
|
||||
/// Detect credits.
|
||||
/// </summary>
|
||||
Credits,
|
||||
|
||||
/// <summary>
|
||||
/// Detect previews.
|
||||
/// </summary>
|
||||
Preview,
|
||||
|
||||
/// <summary>
|
||||
/// Detect recaps.
|
||||
/// </summary>
|
||||
Recap,
|
||||
}
|
||||
|
35
IntroSkipper/Data/AnalyzerAction.cs
Normal file
35
IntroSkipper/Data/AnalyzerAction.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>
|
||||
/// 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,
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
// 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
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of an episode regarding analysis and blacklist status.
|
||||
/// </summary>
|
||||
public class EpisodeState
|
||||
{
|
||||
private readonly bool[] _analyzedStates = new bool[2];
|
||||
|
||||
private readonly bool[] _blacklistedStates = new bool[2];
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the specified analysis mode has been analyzed.
|
||||
/// </summary>
|
||||
/// <param name="mode">The analysis mode to check.</param>
|
||||
/// <returns>True if the mode has been analyzed, false otherwise.</returns>
|
||||
public bool IsAnalyzed(AnalysisMode mode) => _analyzedStates[(int)mode];
|
||||
|
||||
/// <summary>
|
||||
/// Sets the analyzed state for the specified analysis mode.
|
||||
/// </summary>
|
||||
/// <param name="mode">The analysis mode to set.</param>
|
||||
/// <param name="value">The analyzed state to set.</param>
|
||||
public void SetAnalyzed(AnalysisMode mode, bool value) => _analyzedStates[(int)mode] = value;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the specified analysis mode has been blacklisted.
|
||||
/// </summary>
|
||||
/// <param name="mode">The analysis mode to check.</param>
|
||||
/// <returns>True if the mode has been blacklisted, false otherwise.</returns>
|
||||
public bool IsBlacklisted(AnalysisMode mode) => _blacklistedStates[(int)mode];
|
||||
|
||||
/// <summary>
|
||||
/// Sets the blacklisted state for the specified analysis mode.
|
||||
/// </summary>
|
||||
/// <param name="mode">The analysis mode to set.</param>
|
||||
/// <param name="value">The blacklisted state to set.</param>
|
||||
public void SetBlacklisted(AnalysisMode mode, bool value) => _blacklistedStates[(int)mode] = value;
|
||||
|
||||
/// <summary>
|
||||
/// Resets the analyzed states.
|
||||
/// </summary>
|
||||
public void ResetStates()
|
||||
{
|
||||
Array.Clear(_analyzedStates);
|
||||
Array.Clear(_blacklistedStates);
|
||||
}
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an item to ignore.
|
||||
/// </summary>
|
||||
[DataContract(Namespace = "http://schemas.datacontract.org/2004/07/ConfusedPolarBear.Plugin.IntroSkipper")]
|
||||
public class IgnoreListItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IgnoreListItem"/> class.
|
||||
/// </summary>
|
||||
public IgnoreListItem()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IgnoreListItem"/> class.
|
||||
/// </summary>
|
||||
/// <param name="seasonId">The season id.</param>
|
||||
public IgnoreListItem(Guid seasonId)
|
||||
{
|
||||
SeasonId = seasonId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IgnoreListItem"/> class.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to copy.</param>
|
||||
public IgnoreListItem(IgnoreListItem item)
|
||||
{
|
||||
SeasonId = item.SeasonId;
|
||||
IgnoreIntro = item.IgnoreIntro;
|
||||
IgnoreCredits = item.IgnoreCredits;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the season id.
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public Guid SeasonId { get; set; } = Guid.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to ignore the intro.
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public bool IgnoreIntro { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to ignore the credits.
|
||||
/// </summary>
|
||||
[DataMember]
|
||||
public bool IgnoreCredits { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the provided mode to the provided value.
|
||||
/// </summary>
|
||||
/// <param name="mode">Analysis mode.</param>
|
||||
/// <param name="value">Value to set.</param>
|
||||
public void Toggle(AnalysisMode mode, bool value)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case AnalysisMode.Introduction:
|
||||
IgnoreIntro = value;
|
||||
break;
|
||||
case AnalysisMode.Credits:
|
||||
IgnoreCredits = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the provided mode is ignored.
|
||||
/// </summary>
|
||||
/// <param name="mode">Analysis mode.</param>
|
||||
/// <returns>True if ignored, false otherwise.</returns>
|
||||
public bool IsIgnored(AnalysisMode mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
AnalysisMode.Introduction => IgnoreIntro,
|
||||
AnalysisMode.Credits => IgnoreCredits,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
@ -25,16 +25,16 @@ public class QueuedEpisode
|
||||
/// </summary>
|
||||
public Guid EpisodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the season id.
|
||||
/// </summary>
|
||||
public Guid SeasonId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the series id.
|
||||
/// </summary>
|
||||
public Guid SeriesId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the state of the episode.
|
||||
/// </summary>
|
||||
public EpisodeState State => Plugin.Instance!.GetState(EpisodeId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the full path to episode.
|
||||
/// </summary>
|
||||
@ -55,6 +55,11 @@ public class QueuedEpisode
|
||||
/// </summary>
|
||||
public bool IsMovie { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an episode has been analyzed.
|
||||
/// </summary>
|
||||
public bool IsAnalyzed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
|
||||
/// </summary>
|
||||
|
@ -2,7 +2,6 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@ -34,8 +33,8 @@ public class Segment
|
||||
public Segment(Guid episode)
|
||||
{
|
||||
EpisodeId = episode;
|
||||
Start = 0;
|
||||
End = 0;
|
||||
Start = 0.0;
|
||||
End = 0.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -89,29 +88,11 @@ public class Segment
|
||||
/// Gets a value indicating whether this introduction is valid or not.
|
||||
/// Invalid results must not be returned through the API.
|
||||
/// </summary>
|
||||
public bool Valid => End > 0;
|
||||
public bool Valid => End > 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the duration of this intro.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
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,5 +18,15 @@ namespace IntroSkipper.Data
|
||||
/// Gets or sets Credits.
|
||||
/// </summary>
|
||||
public Segment Credits { get; set; } = new Segment();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets Recap.
|
||||
/// </summary>
|
||||
public Segment Recap { get; set; } = new Segment();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets Preview.
|
||||
/// </summary>
|
||||
public Segment Preview { get; set; } = new Segment();
|
||||
}
|
||||
}
|
||||
|
21
IntroSkipper/Data/UpdateAnalyzerActionsRequest.cs
Normal file
21
IntroSkipper/Data/UpdateAnalyzerActionsRequest.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IntroSkipper.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// /// Update analyzer actions request.
|
||||
/// </summary>
|
||||
public class UpdateAnalyzerActionsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets season ID.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets analyzer actions.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<AnalysisMode, AnalyzerAction> AnalyzerActions { get; set; } = new Dictionary<AnalysisMode, AnalyzerAction>();
|
||||
}
|
||||
}
|
59
IntroSkipper/Db/DbSeasonInfo.cs
Normal file
59
IntroSkipper/Db/DbSeasonInfo.cs
Normal file
@ -0,0 +1,59 @@
|
||||
// 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; } = [];
|
||||
}
|
65
IntroSkipper/Db/DbSegment.cs
Normal file
65
IntroSkipper/Db/DbSegment.cs
Normal file
@ -0,0 +1,65 @@
|
||||
// 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));
|
||||
}
|
||||
}
|
147
IntroSkipper/Db/IntroSkipperDbContext.cs
Normal file
147
IntroSkipper/Db/IntroSkipperDbContext.cs
Normal file
@ -0,0 +1,147 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
20
IntroSkipper/Db/IntroSkipperDbContextFactory.cs
Normal file
20
IntroSkipper/Db/IntroSkipperDbContextFactory.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace IntroSkipper.Db;
|
||||
|
||||
/// <summary>
|
||||
/// IntroSkipperDbContext factory.
|
||||
/// </summary>
|
||||
public class IntroSkipperDbContextFactory : IDesignTimeDbContextFactory<IntroSkipperDbContext>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public IntroSkipperDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<IntroSkipperDbContext>();
|
||||
optionsBuilder.UseSqlite("Data Source=introskipper.db")
|
||||
.EnableSensitiveDataLogging(false);
|
||||
|
||||
return new IntroSkipperDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
@ -36,8 +36,6 @@ public static partial class FFmpegWrapper
|
||||
|
||||
private static Dictionary<string, string> ChromaprintLogs { get; set; } = [];
|
||||
|
||||
private static ConcurrentDictionary<(Guid Id, AnalysisMode Mode), Dictionary<uint, int>> InvertedIndexCache { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Check that the installed version of ffmpeg supports chromaprint.
|
||||
/// </summary>
|
||||
@ -134,36 +132,6 @@ public static partial class FFmpegWrapper
|
||||
return Fingerprint(episode, mode, start, end);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at.
|
||||
/// </summary>
|
||||
/// <param name="id">Episode ID.</param>
|
||||
/// <param name="fingerprint">Chromaprint fingerprint.</param>
|
||||
/// <param name="mode">Mode.</param>
|
||||
/// <returns>Inverted index.</returns>
|
||||
public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint, AnalysisMode mode)
|
||||
{
|
||||
if (InvertedIndexCache.TryGetValue((id, mode), out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var invIndex = new Dictionary<uint, int>();
|
||||
|
||||
for (int i = 0; i < fingerprint.Length; i++)
|
||||
{
|
||||
// Get the current point.
|
||||
var point = fingerprint[i];
|
||||
|
||||
// Append the current sample's timecode to the collection for this point.
|
||||
invIndex[point] = i;
|
||||
}
|
||||
|
||||
InvertedIndexCache[(id, mode)] = invIndex;
|
||||
|
||||
return invIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect ranges of silence in the provided episode.
|
||||
/// </summary>
|
||||
|
218
IntroSkipper/Helper/LegacyMigrations.cs
Normal file
218
IntroSkipper/Helper/LegacyMigrations.cs
Normal file
@ -0,0 +1,218 @@
|
||||
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);
|
||||
}
|
||||
}
|
42
IntroSkipper/Helper/OperatingSystem.cs
Normal file
42
IntroSkipper/Helper/OperatingSystem.cs
Normal file
@ -0,0 +1,42 @@
|
||||
// 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 IntroSkipper.Data;
|
||||
|
||||
namespace IntroSkipper
|
||||
namespace IntroSkipper.Helper
|
||||
{
|
||||
internal sealed class XmlSerializationHelper
|
||||
{
|
||||
|
@ -2,8 +2,8 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>IntroSkipper</RootNamespace>
|
||||
<AssemblyVersion>1.10.9.2</AssemblyVersion>
|
||||
<FileVersion>1.10.9.2</FileVersion>
|
||||
<AssemblyVersion>1.10.10.11</AssemblyVersion>
|
||||
<FileVersion>1.10.10.11</FileVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Nullable>enable</Nullable>
|
||||
@ -11,8 +11,10 @@
|
||||
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.9.*" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.9.*" />
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.*-*" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.10.*-*" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
||||
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
||||
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
||||
<PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.556" PrivateAssets="All" />
|
||||
@ -23,8 +25,4 @@
|
||||
<EmbeddedResource Include="Configuration\visualizer.js" />
|
||||
<EmbeddedResource Include="Configuration\inject.js" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Manager\" />
|
||||
<Folder Include="Services\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
25
IntroSkipper/IntroSkipper.sln
Normal file
25
IntroSkipper/IntroSkipper.sln
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
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
|
@ -1,119 +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.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");
|
||||
}
|
||||
}
|
68
IntroSkipper/Manager/MediaSegmentUpdateManager.cs
Normal file
68
IntroSkipper/Manager/MediaSegmentUpdateManager.cs
Normal file
@ -0,0 +1,68 @@
|
||||
// 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 Microsoft.Extensions.Logging;
|
||||
|
||||
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)
|
||||
namespace IntroSkipper.Manager
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages enqueuing library items for analysis.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="QueueManager"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryManager)
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager = libraryManager;
|
||||
private readonly ILogger<QueueManager> _logger = logger;
|
||||
private readonly Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes = [];
|
||||
@ -152,10 +152,13 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
|
||||
{
|
||||
QueueEpisode(episode);
|
||||
}
|
||||
else if (_analyzeMovies && item is Movie movie)
|
||||
else if (item is Movie movie)
|
||||
{
|
||||
if (_analyzeMovies)
|
||||
{
|
||||
QueueMovie(movie);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Item {Name} is not an episode or movie", item.Name);
|
||||
@ -219,6 +222,7 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
|
||||
SeriesName = episode.SeriesName,
|
||||
SeasonNumber = episode.AiredSeasonNumber ?? 0,
|
||||
SeriesId = episode.SeriesId,
|
||||
SeasonId = episode.SeasonId,
|
||||
EpisodeId = episode.Id,
|
||||
Name = episode.Name,
|
||||
IsAnime = isAnime,
|
||||
@ -234,6 +238,7 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
|
||||
private void QueueMovie(Movie movie)
|
||||
{
|
||||
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");
|
||||
|
||||
if (string.IsNullOrEmpty(movie.Path))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@ -245,17 +250,22 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
|
||||
|
||||
// Allocate a new list for each Movie
|
||||
_queuedEpisodes.TryAdd(movie.Id, []);
|
||||
|
||||
var duration = TimeSpan.FromTicks(movie.RunTimeTicks ?? 0).TotalSeconds;
|
||||
|
||||
_queuedEpisodes[movie.Id].Add(new QueuedEpisode
|
||||
{
|
||||
SeriesName = movie.Name,
|
||||
SeriesId = movie.Id,
|
||||
SeasonId = movie.Id,
|
||||
EpisodeId = movie.Id,
|
||||
Name = movie.Name,
|
||||
Path = movie.Path,
|
||||
Duration = Convert.ToInt32(duration),
|
||||
CreditsFingerprintStart = Convert.ToInt32(duration - pluginInstance.Configuration.MaximumMovieCreditsDuration),
|
||||
IsMovie = true
|
||||
});
|
||||
|
||||
pluginInstance.TotalQueued++;
|
||||
}
|
||||
|
||||
@ -284,18 +294,19 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
|
||||
/// <param name="candidates">Queued media items.</param>
|
||||
/// <param name="modes">Analysis mode.</param>
|
||||
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
|
||||
public (IReadOnlyList<QueuedEpisode> VerifiedItems, IReadOnlyCollection<AnalysisMode> RequiredModes)
|
||||
internal (IReadOnlyList<QueuedEpisode> QueuedEpisodes, IReadOnlyCollection<AnalysisMode> RequiredModes)
|
||||
VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<AnalysisMode> modes)
|
||||
{
|
||||
var verified = new List<QueuedEpisode>();
|
||||
var reqModes = new HashSet<AnalysisMode>();
|
||||
var requiredModes = new HashSet<AnalysisMode>();
|
||||
|
||||
var episodeIds = Plugin.Instance!.GetEpisodeIds(candidates[0].SeasonId);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
@ -305,22 +316,9 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
|
||||
|
||||
foreach (var mode in modes)
|
||||
{
|
||||
if (candidate.State.IsAnalyzed(mode) || candidate.State.IsBlacklisted(mode))
|
||||
if (!episodeIds.TryGetValue(mode, out var ids) || !ids.Contains(candidate.EpisodeId) || Plugin.Instance!.AnalyzeAgain)
|
||||
{
|
||||
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);
|
||||
requiredModes.Add(mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -334,6 +332,7 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
|
||||
}
|
||||
}
|
||||
|
||||
return (verified, reqModes);
|
||||
return (verified, requiredModes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
73
IntroSkipper/Migrations/20241116153434_InitialCreate.Designer.cs
generated
Normal file
73
IntroSkipper/Migrations/20241116153434_InitialCreate.Designer.cs
generated
Normal file
@ -0,0 +1,73 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using IntroSkipper.Db;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace IntroSkipper.Migrations
|
||||
{
|
||||
[DbContext(typeof(IntroSkipperDbContext))]
|
||||
[Migration("20241116153434_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
|
||||
|
||||
modelBuilder.Entity("IntroSkipper.Db.DbSeasonInfo", b =>
|
||||
{
|
||||
b.Property<Guid>("SeasonId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Action")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("EpisodeIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeasonId", "Type");
|
||||
|
||||
b.HasIndex("SeasonId");
|
||||
|
||||
b.ToTable("DbSeasonInfo", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("IntroSkipper.Db.DbSegment", b =>
|
||||
{
|
||||
b.Property<Guid>("ItemId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("End")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("REAL")
|
||||
.HasDefaultValue(0.0);
|
||||
|
||||
b.Property<double>("Start")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("REAL")
|
||||
.HasDefaultValue(0.0);
|
||||
|
||||
b.HasKey("ItemId", "Type");
|
||||
|
||||
b.HasIndex("ItemId");
|
||||
|
||||
b.ToTable("DbSegment", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
63
IntroSkipper/Migrations/20241116153434_InitialCreate.cs
Normal file
63
IntroSkipper/Migrations/20241116153434_InitialCreate.cs
Normal file
@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace IntroSkipper.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DbSeasonInfo",
|
||||
columns: table => new
|
||||
{
|
||||
SeasonId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Action = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
|
||||
EpisodeIds = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DbSeasonInfo", x => new { x.SeasonId, x.Type });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DbSegment",
|
||||
columns: table => new
|
||||
{
|
||||
ItemId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Start = table.Column<double>(type: "REAL", nullable: false, defaultValue: 0.0),
|
||||
End = table.Column<double>(type: "REAL", nullable: false, defaultValue: 0.0)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DbSegment", x => new { x.ItemId, x.Type });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DbSeasonInfo_SeasonId",
|
||||
table: "DbSeasonInfo",
|
||||
column: "SeasonId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DbSegment_ItemId",
|
||||
table: "DbSegment",
|
||||
column: "ItemId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DbSeasonInfo");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DbSegment");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using IntroSkipper.Db;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace IntroSkipper.Migrations
|
||||
{
|
||||
[DbContext(typeof(IntroSkipperDbContext))]
|
||||
partial class IntroSkipperDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
|
||||
|
||||
modelBuilder.Entity("IntroSkipper.Db.DbSeasonInfo", b =>
|
||||
{
|
||||
b.Property<Guid>("SeasonId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Action")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("EpisodeIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeasonId", "Type");
|
||||
|
||||
b.HasIndex("SeasonId");
|
||||
|
||||
b.ToTable("DbSeasonInfo", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("IntroSkipper.Db.DbSegment", b =>
|
||||
{
|
||||
b.Property<Guid>("ItemId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("End")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("REAL")
|
||||
.HasDefaultValue(0.0);
|
||||
|
||||
b.Property<double>("Start")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("REAL")
|
||||
.HasDefaultValue(0.0);
|
||||
|
||||
b.HasKey("ItemId", "Type");
|
||||
|
||||
b.HasIndex("ItemId");
|
||||
|
||||
b.ToTable("DbSegment", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
@ -6,12 +6,11 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml;
|
||||
using System.Xml.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Configuration;
|
||||
using IntroSkipper.Data;
|
||||
using MediaBrowser.Common;
|
||||
using IntroSkipper.Db;
|
||||
using IntroSkipper.Helper;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
@ -21,7 +20,7 @@ using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using MediaBrowser.Model.Updates;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace IntroSkipper;
|
||||
@ -31,20 +30,14 @@ namespace IntroSkipper;
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
private readonly object _serializationLock = new();
|
||||
private readonly object _introsLock = new();
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IItemRepository _itemRepository;
|
||||
private readonly IApplicationHost _applicationHost;
|
||||
private readonly ILogger<Plugin> _logger;
|
||||
private readonly string _introPath;
|
||||
private readonly string _creditsPath;
|
||||
private string _ignorelistPath;
|
||||
private readonly string _dbPath;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationHost">Application host.</param>
|
||||
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
|
||||
/// <param name="serverConfiguration">Server configuration manager.</param>
|
||||
@ -52,7 +45,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// <param name="itemRepository">Item repository.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public Plugin(
|
||||
IApplicationHost applicationHost,
|
||||
IApplicationPaths applicationPaths,
|
||||
IXmlSerializer xmlSerializer,
|
||||
IServerConfigurationManager serverConfiguration,
|
||||
@ -63,7 +55,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
Instance = this;
|
||||
|
||||
_applicationHost = applicationHost;
|
||||
_libraryManager = libraryManager;
|
||||
_itemRepository = itemRepository;
|
||||
_logger = logger;
|
||||
@ -77,9 +68,8 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
|
||||
var introsDirectory = Path.Join(applicationPaths.DataPath, pluginDirName);
|
||||
FingerprintCachePath = Path.Join(introsDirectory, pluginCachePath);
|
||||
_introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml");
|
||||
_creditsPath = Path.Join(applicationPaths.DataPath, pluginDirName, "credits.xml");
|
||||
_ignorelistPath = Path.Join(applicationPaths.DataPath, pluginDirName, "ignorelist.xml");
|
||||
|
||||
_dbPath = Path.Join(applicationPaths.DataPath, pluginDirName, "introskipper.db");
|
||||
|
||||
// Create the base & cache directories (if needed).
|
||||
if (!Directory.Exists(FingerprintCachePath))
|
||||
@ -87,103 +77,44 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
Directory.CreateDirectory(FingerprintCachePath);
|
||||
}
|
||||
|
||||
// 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))
|
||||
{
|
||||
// Initialize database, restore timestamps if available.
|
||||
try
|
||||
{
|
||||
XmlSerializer serializer = new XmlSerializer(typeof(PluginConfiguration));
|
||||
using (FileStream fileStream = new FileStream(oldConfigFile, FileMode.Open))
|
||||
{
|
||||
var settings = new XmlReaderSettings
|
||||
{
|
||||
DtdProcessing = DtdProcessing.Prohibit, // Disable DTD processing
|
||||
XmlResolver = null // Disable the XmlResolver
|
||||
};
|
||||
|
||||
using (var reader = XmlReader.Create(fileStream, settings))
|
||||
{
|
||||
if (serializer.Deserialize(reader) is PluginConfiguration oldConfig)
|
||||
{
|
||||
Instance.UpdateConfiguration(oldConfig);
|
||||
File.Delete(oldConfigFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
using var db = new IntroSkipperDbContext(_dbPath);
|
||||
db.ApplyMigrations();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Handle exceptions, such as file not found, deserialization errors, etc.
|
||||
_logger.LogWarning("Something stupid happened: {Exception}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
MigrateRepoUrl(serverConfiguration);
|
||||
|
||||
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
|
||||
try
|
||||
{
|
||||
RestoreTimestamps();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex);
|
||||
logger.LogWarning("Error initializing database: {Exception}", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LoadIgnoreList();
|
||||
LegacyMigrations.MigrateAll(this, serverConfiguration, logger, applicationPaths);
|
||||
}
|
||||
catch (Exception 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);
|
||||
logger.LogError("Failed to perform migrations. Error: {Error}", ex);
|
||||
}
|
||||
|
||||
FFmpegWrapper.CheckFFmpegVersion();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the results of fingerprinting all episodes.
|
||||
/// Gets the path to the database.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<Guid, Segment> Intros { get; } = new();
|
||||
public string DbPath => _dbPath;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all discovered ending credits.
|
||||
/// Gets or sets a value indicating whether to analyze again.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<Guid, Segment> Credits { get; } = new();
|
||||
public bool AnalyzeAgain { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recent media item queue.
|
||||
/// </summary>
|
||||
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>
|
||||
/// Gets or sets the total number of episodes in the queue.
|
||||
/// </summary>
|
||||
@ -215,111 +146,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// </summary>
|
||||
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 />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
@ -343,25 +169,12 @@ 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)
|
||||
{
|
||||
return id != Guid.Empty ? _libraryManager.GetItemById(id) : null;
|
||||
}
|
||||
|
||||
internal IReadOnlyList<Folder> GetCollectionFolders(Guid id)
|
||||
internal ICollection<Folder> GetCollectionFolders(Guid id)
|
||||
{
|
||||
var item = GetItem(id);
|
||||
return item is not null ? _libraryManager.GetCollectionFolders(item) : [];
|
||||
@ -403,186 +216,111 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
return _itemRepository.GetChapters(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the state for this item.
|
||||
/// </summary>
|
||||
/// <param name="id">Item ID.</param>
|
||||
/// <returns>State of this item.</returns>
|
||||
internal EpisodeState GetState(Guid id) => EpisodeStates.GetOrAdd(id, _ => new EpisodeState());
|
||||
internal async Task UpdateTimestampAsync(Segment segment, AnalysisMode mode)
|
||||
{
|
||||
using var db = new IntroSkipperDbContext(_dbPath);
|
||||
|
||||
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
|
||||
{
|
||||
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"
|
||||
];
|
||||
// Access the current server configuration
|
||||
var config = serverConfiguration.Configuration;
|
||||
var existing = await db.DbSegment
|
||||
.FirstOrDefaultAsync(s => s.ItemId == segment.EpisodeId && s.Type == mode)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Get the list of current plugin repositories
|
||||
var pluginRepositories = config.PluginRepositories.ToList();
|
||||
|
||||
// check if old plugins exits
|
||||
if (pluginRepositories.Exists(repo => repo.Url != null && oldRepos.Contains(repo.Url)))
|
||||
var dbSegment = new DbSegment(segment, mode);
|
||||
if (existing is not null)
|
||||
{
|
||||
// remove all old plugins
|
||||
pluginRepositories.RemoveAll(repo => repo.Url != null && oldRepos.Contains(repo.Url));
|
||||
|
||||
// Add repository only if it does not exit and the OverideManifestUrl Option is activated
|
||||
if (!pluginRepositories.Exists(repo => repo.Url == "https://manifest.intro-skipper.org/manifest.json") && Instance!.Configuration.OverrideManifestUrl)
|
||||
{
|
||||
// Add the new repository to the list
|
||||
pluginRepositories.Add(new RepositoryInfo
|
||||
{
|
||||
Name = "intro skipper (automatically migrated by plugin)",
|
||||
Url = "https://manifest.intro-skipper.org/manifest.json",
|
||||
Enabled = true,
|
||||
});
|
||||
}
|
||||
|
||||
// Update the configuration with the new repository list
|
||||
config.PluginRepositories = [.. pluginRepositories];
|
||||
|
||||
// Save the updated configuration
|
||||
serverConfiguration.SaveConfiguration();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error occurred while migrating repo URL");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
string searchPattern = "dashboard-dashboard.*.chunk.js";
|
||||
string[] filePaths = Directory.GetFiles(webPath, searchPattern, SearchOption.TopDirectoryOnly);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (webVersionString != "unknown")
|
||||
{
|
||||
// append Revision
|
||||
webVersionString += ".0";
|
||||
if (Version.TryParse(webVersionString, out var webversion))
|
||||
{
|
||||
if (_applicationHost.ApplicationVersion != webversion)
|
||||
{
|
||||
_logger.LogWarning("The jellyfin-web <{WebVersion}> NOT compatible with Jellyfin <{JellyfinVersion}>", webVersionString, _applicationHost.ApplicationVersion);
|
||||
db.Entry(existing).CurrentValues.SetValues(dbSegment);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("The jellyfin-web <{WebVersion}> compatible with Jellyfin <{JellyfinVersion}>", webVersionString, _applicationHost.ApplicationVersion);
|
||||
}
|
||||
}
|
||||
db.DbSegment.Add(dbSegment);
|
||||
}
|
||||
|
||||
// search for controllers/playback/video/index.html
|
||||
searchPattern = "playback-video-index-html.*.chunk.js";
|
||||
filePaths = Directory.GetFiles(webPath, searchPattern, SearchOption.TopDirectoryOnly);
|
||||
|
||||
// should be only one file but this safer
|
||||
foreach (var file in filePaths)
|
||||
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// search for class btnSkipIntro
|
||||
if (File.ReadAllText(file).Contains("btnSkipIntro", StringComparison.OrdinalIgnoreCase))
|
||||
_logger.LogError(ex, "Failed to update timestamp for episode {EpisodeId}", segment.EpisodeId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal IReadOnlyDictionary<AnalysisMode, Segment> GetTimestamps(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Found a modified version of jellyfin-web with built-in skip button support.");
|
||||
return;
|
||||
}
|
||||
using var db = new IntroSkipperDbContext(_dbPath);
|
||||
return db.DbSegment.Where(s => s.ItemId == id)
|
||||
.ToDictionary(s => s.Type, s => s.ToSegment());
|
||||
}
|
||||
|
||||
// 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))
|
||||
internal async Task CleanTimestamps(IEnumerable<Guid> episodeIds)
|
||||
{
|
||||
_logger.LogInformation("The skip button has already been injected.");
|
||||
return;
|
||||
using var db = new IntroSkipperDbContext(_dbPath);
|
||||
db.DbSegment.RemoveRange(db.DbSegment
|
||||
.Where(s => !episodeIds.Contains(s.ItemId)));
|
||||
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// remove old version if necessary
|
||||
pattern = @"<script src=""configurationpage\?name=skip-intro-button\.js.*<\/script>";
|
||||
contents = Regex.Replace(contents, pattern, string.Empty, RegexOptions.IgnoreCase);
|
||||
internal async Task SetAnalyzerActionAsync(Guid id, IReadOnlyDictionary<AnalysisMode, AnalyzerAction> analyzerActions)
|
||||
{
|
||||
using var db = new IntroSkipperDbContext(_dbPath);
|
||||
var existingEntries = await db.DbSeasonInfo
|
||||
.Where(s => s.SeasonId == id)
|
||||
.ToDictionaryAsync(s => s.Type)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Inject a link to the script at the end of the <head> section.
|
||||
// A regex is used here to ensure the replacement is only done once.
|
||||
Regex headEnd = new Regex(@"</head>", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
|
||||
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
|
||||
foreach (var (mode, action) in analyzerActions)
|
||||
{
|
||||
if (existingEntries.TryGetValue(mode, out var existing))
|
||||
{
|
||||
db.Entry(existing).Property(s => s.Action).CurrentValue = action;
|
||||
}
|
||||
else
|
||||
{
|
||||
db.DbSeasonInfo.Add(new DbSeasonInfo(id, mode, action));
|
||||
}
|
||||
}
|
||||
|
||||
// Write the modified file contents
|
||||
File.WriteAllText(indexPath, contents);
|
||||
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Skip button added successfully.");
|
||||
internal async Task SetEpisodeIdsAsync(Guid id, AnalysisMode mode, IEnumerable<Guid> episodeIds)
|
||||
{
|
||||
using var db = new IntroSkipperDbContext(_dbPath);
|
||||
var seasonInfo = db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode);
|
||||
|
||||
if (seasonInfo is null)
|
||||
{
|
||||
seasonInfo = new DbSeasonInfo(id, mode, AnalyzerAction.Default, episodeIds);
|
||||
db.DbSeasonInfo.Add(seasonInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.Entry(seasonInfo).Property(s => s.EpisodeIds).CurrentValue = episodeIds;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal IReadOnlyDictionary<AnalysisMode, IEnumerable<Guid>> GetEpisodeIds(Guid id)
|
||||
{
|
||||
using var db = new IntroSkipperDbContext(_dbPath);
|
||||
return db.DbSeasonInfo.Where(s => s.SeasonId == id)
|
||||
.ToDictionary(s => s.Type, s => s.EpisodeIds);
|
||||
}
|
||||
|
||||
internal AnalyzerAction GetAnalyzerAction(Guid id, AnalysisMode mode)
|
||||
{
|
||||
using var db = new IntroSkipperDbContext(_dbPath);
|
||||
return db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode)?.Action ?? AnalyzerAction.Default;
|
||||
}
|
||||
|
||||
internal async Task CleanSeasonInfoAsync(IEnumerable<Guid> ids)
|
||||
{
|
||||
using var db = new IntroSkipperDbContext(_dbPath);
|
||||
var obsoleteSeasons = await db.DbSeasonInfo
|
||||
.Where(s => !ids.Contains(s.SeasonId))
|
||||
.ToListAsync().ConfigureAwait(false);
|
||||
db.DbSeasonInfo.RemoveRange(obsoleteSeasons);
|
||||
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using IntroSkipper.Manager;
|
||||
using IntroSkipper.Providers;
|
||||
using IntroSkipper.Services;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@ -16,8 +19,9 @@ namespace IntroSkipper
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
serviceCollection.AddHostedService<AutoSkip>();
|
||||
serviceCollection.AddHostedService<AutoSkipCredits>();
|
||||
serviceCollection.AddHostedService<Entrypoint>();
|
||||
serviceCollection.AddSingleton<IMediaSegmentProvider, SegmentProvider>();
|
||||
serviceCollection.AddSingleton<MediaSegmentUpdateManager>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
90
IntroSkipper/Providers/SegmentProvider.cs
Normal file
90
IntroSkipper/Providers/SegmentProvider.cs
Normal file
@ -0,0 +1,90 @@
|
||||
// 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,12 +3,14 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Analyzers;
|
||||
using IntroSkipper.Configuration;
|
||||
using IntroSkipper.Data;
|
||||
using IntroSkipper.Db;
|
||||
using IntroSkipper.Manager;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@ -17,157 +19,136 @@ namespace IntroSkipper.ScheduledTasks;
|
||||
/// <summary>
|
||||
/// Common code shared by all media item analyzer tasks.
|
||||
/// </summary>
|
||||
public class BaseItemAnalyzerTask
|
||||
{
|
||||
private readonly IReadOnlyCollection<AnalysisMode> _analysisModes;
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <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,
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="BaseItemAnalyzerTask"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="logger">Task logger.</param>
|
||||
/// <param name="loggerFactory">Logger factory.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
/// <param name="mediaSegmentUpdateManager">MediaSegmentUpdateManager.</param>
|
||||
public class BaseItemAnalyzerTask(
|
||||
ILogger logger,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_analysisModes = modes;
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
_libraryManager = libraryManager;
|
||||
|
||||
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
|
||||
{
|
||||
EdlManager.Initialize(_logger);
|
||||
}
|
||||
}
|
||||
ILibraryManager libraryManager,
|
||||
MediaSegmentUpdateManager mediaSegmentUpdateManager)
|
||||
{
|
||||
private readonly ILogger _logger = logger;
|
||||
private readonly ILoggerFactory _loggerFactory = loggerFactory;
|
||||
private readonly ILibraryManager _libraryManager = libraryManager;
|
||||
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
||||
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
|
||||
/// <summary>
|
||||
/// Analyze all media items on the server.
|
||||
/// </summary>
|
||||
/// <param name="progress">Progress.</param>
|
||||
/// <param name="progress">Progress reporter.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <param name="seasonsToAnalyze">Season Ids to analyze.</param>
|
||||
public void AnalyzeItems(
|
||||
/// <param name="seasonsToAnalyze">Season IDs to analyze.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async Task AnalyzeItemsAsync(
|
||||
IProgress<double> progress,
|
||||
CancellationToken cancellationToken,
|
||||
IReadOnlyCollection<Guid>? seasonsToAnalyze = null)
|
||||
{
|
||||
// Assert that ffmpeg with chromaprint is installed
|
||||
if (Plugin.Instance!.Configuration.WithChromaprint && !FFmpegWrapper.CheckFFmpegVersion())
|
||||
if (_config.WithChromaprint && !FFmpegWrapper.CheckFFmpegVersion())
|
||||
{
|
||||
throw new FingerprintException(
|
||||
"Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg6. If Jellyfin is running in a container, upgrade to version 10.9.0 or newer.");
|
||||
"Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg7. If Jellyfin is running in a container, upgrade to version 10.10.0 or newer.");
|
||||
}
|
||||
|
||||
HashSet<AnalysisMode> modes = [
|
||||
.. _config.ScanIntroduction ? [AnalysisMode.Introduction] : Array.Empty<AnalysisMode>(),
|
||||
.. _config.ScanCredits ? [AnalysisMode.Credits] : Array.Empty<AnalysisMode>(),
|
||||
.. _config.ScanRecap ? [AnalysisMode.Recap] : Array.Empty<AnalysisMode>(),
|
||||
.. _config.ScanPreview ? [AnalysisMode.Preview] : Array.Empty<AnalysisMode>()
|
||||
];
|
||||
|
||||
var queueManager = new QueueManager(
|
||||
_loggerFactory.CreateLogger<QueueManager>(),
|
||||
_libraryManager);
|
||||
|
||||
var queue = queueManager.GetMediaItems();
|
||||
|
||||
// Filter the queue based on seasonsToAnalyze
|
||||
if (seasonsToAnalyze is { Count: > 0 })
|
||||
if (seasonsToAnalyze?.Count > 0)
|
||||
{
|
||||
queue = queue.Where(kvp => seasonsToAnalyze.Contains(kvp.Key)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||
queue = queue.Where(kvp => seasonsToAnalyze.Contains(kvp.Key))
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||
}
|
||||
|
||||
int totalQueued = queue.Sum(kvp => kvp.Value.Count) * _analysisModes.Count;
|
||||
int totalQueued = queue.Sum(kvp => kvp.Value.Count) * modes.Count;
|
||||
if (totalQueued == 0)
|
||||
{
|
||||
throw new FingerprintException(
|
||||
"No libraries selected for analysis. Please visit the plugin settings to configure.");
|
||||
}
|
||||
|
||||
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
|
||||
{
|
||||
EdlManager.LogConfiguration();
|
||||
}
|
||||
|
||||
var totalProcessed = 0;
|
||||
int totalProcessed = 0;
|
||||
var options = new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Plugin.Instance.Configuration.MaxParallelism
|
||||
MaxDegreeOfParallelism = Math.Max(1, _config.MaxParallelism),
|
||||
CancellationToken = cancellationToken
|
||||
};
|
||||
|
||||
Parallel.ForEach(queue, options, season =>
|
||||
await Parallel.ForEachAsync(queue, options, async (season, ct) =>
|
||||
{
|
||||
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 updateMediaSegments = false;
|
||||
|
||||
var (episodes, requiredModes) = queueManager.VerifyQueue(season.Value, modes);
|
||||
if (episodes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var first = episodes[0];
|
||||
if (requiredModes.Count == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"All episodes in {Name} season {Season} have already been analyzed",
|
||||
first.SeriesName,
|
||||
first.SeasonNumber);
|
||||
|
||||
Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly
|
||||
progress.Report(totalProcessed * 100 / totalQueued);
|
||||
}
|
||||
else if (_analysisModes.Count != requiredModes.Count)
|
||||
{
|
||||
Interlocked.Add(ref totalProcessed, episodes.Count);
|
||||
progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
var firstEpisode = episodes[0];
|
||||
if (modes.Count != requiredModes.Count)
|
||||
{
|
||||
return;
|
||||
Interlocked.Add(ref totalProcessed, episodes.Count * (modes.Count - requiredModes.Count));
|
||||
progress.Report((double)totalProcessed / totalQueued * 100);
|
||||
}
|
||||
|
||||
foreach (AnalysisMode mode in requiredModes)
|
||||
foreach (var mode in requiredModes)
|
||||
{
|
||||
var analyzed = AnalyzeItems(episodes, mode, cancellationToken);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
int analyzed = await AnalyzeItemsAsync(
|
||||
episodes,
|
||||
mode,
|
||||
ct).ConfigureAwait(false);
|
||||
Interlocked.Add(ref totalProcessed, analyzed);
|
||||
|
||||
writeEdl = analyzed > 0 || Plugin.Instance.Configuration.RegenerateEdlFiles;
|
||||
|
||||
progress.Report(totalProcessed * 100 / totalQueued);
|
||||
updateMediaSegments = analyzed > 0 || updateMediaSegments;
|
||||
progress.Report((double)totalProcessed / totalQueued * 100);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Analysis was canceled.");
|
||||
}
|
||||
catch (FingerprintException ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to analyze {Series} season {Season}: unable to fingerprint: {Ex}",
|
||||
first.SeriesName,
|
||||
first.SeasonNumber,
|
||||
ex);
|
||||
_logger.LogWarning(ex, "Fingerprint exception during analysis.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "An unexpected error occurred during analysis.");
|
||||
throw;
|
||||
}
|
||||
|
||||
if (writeEdl && Plugin.Instance.Configuration.EdlAction != EdlAction.None)
|
||||
if (_config.RebuildMediaSegments || (updateMediaSegments && _config.UpdateMediaSegments))
|
||||
{
|
||||
EdlManager.UpdateEDLFiles(episodes);
|
||||
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, ct).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (Plugin.Instance.Configuration.RegenerateEdlFiles)
|
||||
Plugin.Instance!.AnalyzeAgain = false;
|
||||
|
||||
if (_config.RebuildMediaSegments)
|
||||
{
|
||||
_logger.LogInformation("Turning EDL file regeneration flag off");
|
||||
Plugin.Instance.Configuration.RegenerateEdlFiles = false;
|
||||
Plugin.Instance.SaveConfiguration();
|
||||
_logger.LogInformation("Regenerated media segments.");
|
||||
_config.RebuildMediaSegments = false;
|
||||
Plugin.Instance!.SaveConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
@ -177,27 +158,27 @@ public class BaseItemAnalyzerTask
|
||||
/// <param name="items">Media items to analyze.</param>
|
||||
/// <param name="mode">Analysis mode.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of items that were successfully analyzed.</returns>
|
||||
private int AnalyzeItems(
|
||||
/// <returns>Number of items successfully analyzed.</returns>
|
||||
private async Task<int> AnalyzeItemsAsync(
|
||||
IReadOnlyList<QueuedEpisode> items,
|
||||
AnalysisMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var totalItems = items.Count;
|
||||
|
||||
// Only analyze specials (season 0) if the user has opted in.
|
||||
var first = items[0];
|
||||
if (!first.IsMovie && first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
|
||||
if (!first.IsMovie && first.SeasonNumber == 0 && !_config.AnalyzeSeasonZero)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Remove from Blacklist
|
||||
foreach (var item in items.Where(e => e.State.IsBlacklisted(mode)))
|
||||
// Reset the IsAnalyzed flag for all items
|
||||
foreach (var item in items)
|
||||
{
|
||||
item.State.SetBlacklisted(mode, false);
|
||||
item.IsAnalyzed = false;
|
||||
}
|
||||
|
||||
// Get the analyzer action for the current mode
|
||||
var action = Plugin.Instance!.GetAnalyzerAction(first.SeasonId, mode);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}",
|
||||
mode,
|
||||
@ -205,22 +186,30 @@ public class BaseItemAnalyzerTask
|
||||
first.SeriesName,
|
||||
first.SeasonNumber);
|
||||
|
||||
var analyzers = new Collection<IMediaFileAnalyzer>
|
||||
{
|
||||
new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>())
|
||||
};
|
||||
// Create a list of analyzers to use for the current mode
|
||||
var analyzers = new List<IMediaFileAnalyzer>();
|
||||
|
||||
if (first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint && !first.IsMovie)
|
||||
if (action is AnalyzerAction.Chapter or AnalyzerAction.Default)
|
||||
{
|
||||
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
|
||||
}
|
||||
|
||||
if (first.IsAnime && _config.WithChromaprint &&
|
||||
mode is not (AnalysisMode.Recap or AnalysisMode.Preview) &&
|
||||
action is AnalyzerAction.Default or AnalyzerAction.Chromaprint)
|
||||
{
|
||||
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
|
||||
}
|
||||
|
||||
if (mode == AnalysisMode.Credits)
|
||||
if (mode is AnalysisMode.Credits &&
|
||||
action is AnalyzerAction.Default or AnalyzerAction.BlackFrame)
|
||||
{
|
||||
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
|
||||
}
|
||||
|
||||
if (!first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint && !first.IsMovie)
|
||||
if (!first.IsAnime && !first.IsMovie &&
|
||||
mode is not (AnalysisMode.Recap or AnalysisMode.Preview) &&
|
||||
action is AnalyzerAction.Default or AnalyzerAction.Chromaprint)
|
||||
{
|
||||
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
|
||||
}
|
||||
@ -229,16 +218,13 @@ public class BaseItemAnalyzerTask
|
||||
// analyzed items from the queue.
|
||||
foreach (var analyzer in analyzers)
|
||||
{
|
||||
items = analyzer.AnalyzeMediaFiles(items, mode, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
items = await analyzer.AnalyzeMediaFiles(items, mode, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Add items without intros/credits to blacklist.
|
||||
foreach (var item in items.Where(e => !e.State.IsAnalyzed(mode)))
|
||||
{
|
||||
item.State.SetBlacklisted(mode, true);
|
||||
totalItems -= 1;
|
||||
}
|
||||
// Set the episode IDs for the analyzed items
|
||||
await Plugin.Instance!.SetEpisodeIdsAsync(first.SeasonId, mode, items.Select(i => i.EpisodeId)).ConfigureAwait(false);
|
||||
|
||||
return totalItems;
|
||||
return items.Where(i => i.IsAnalyzed).Count();
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Manager;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -68,7 +69,7 @@ public class CleanCacheTask : IScheduledTask
|
||||
/// <param name="progress">Task progress.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_libraryManager is null)
|
||||
{
|
||||
@ -81,24 +82,17 @@ public class CleanCacheTask : IScheduledTask
|
||||
|
||||
// Retrieve media items and get valid episode IDs
|
||||
var queue = queueManager.GetMediaItems();
|
||||
var validEpisodeIds = new HashSet<Guid>(queue.Values.SelectMany(episodes => episodes.Select(e => e.EpisodeId)));
|
||||
var validEpisodeIds = queue.Values
|
||||
.SelectMany(episodes => episodes.Select(e => e.EpisodeId))
|
||||
.ToHashSet();
|
||||
|
||||
Plugin.Instance!.CleanTimestamps(validEpisodeIds);
|
||||
await Plugin.Instance!.CleanTimestamps(validEpisodeIds).ConfigureAwait(false);
|
||||
|
||||
// Identify invalid episode IDs
|
||||
var invalidEpisodeIds = Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath)
|
||||
.Select(filePath =>
|
||||
{
|
||||
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>()
|
||||
.Select(filePath => Path.GetFileNameWithoutExtension(filePath).Split('-')[0])
|
||||
.Where(episodeIdStr => Guid.TryParse(episodeIdStr, out var episodeId) && !validEpisodeIds.Contains(episodeId))
|
||||
.Select(Guid.Parse)
|
||||
.ToHashSet();
|
||||
|
||||
// Delete cache files for invalid episode IDs
|
||||
@ -108,31 +102,12 @@ public class CleanCacheTask : IScheduledTask
|
||||
FFmpegWrapper.DeleteEpisodeCache(episodeId);
|
||||
}
|
||||
|
||||
// Clean up ignore list by removing items that are no longer exist..
|
||||
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 _);
|
||||
}
|
||||
}
|
||||
// Clean up Season information by removing items that are no longer exist.
|
||||
await Plugin.Instance!.CleanSeasonInfoAsync(queue.Keys).ConfigureAwait(false);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Plugin.Instance!.AnalyzeAgain = true;
|
||||
|
||||
return Task.CompletedTask;
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -1,109 +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 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 [];
|
||||
}
|
||||
}
|
@ -1,108 +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 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 [];
|
||||
}
|
||||
}
|
@ -5,7 +5,8 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Data;
|
||||
using IntroSkipper.Manager;
|
||||
using IntroSkipper.Services;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -13,36 +14,33 @@ using Microsoft.Extensions.Logging;
|
||||
namespace IntroSkipper.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Analyze all television episodes for introduction sequences.
|
||||
/// Analyze all television episodes for media segments.
|
||||
/// </summary>
|
||||
public class DetectIntrosCreditsTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<DetectIntrosCreditsTask> _logger;
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DetectIntrosCreditsTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public DetectIntrosCreditsTask(
|
||||
ILogger<DetectIntrosCreditsTask> logger,
|
||||
/// <remarks>
|
||||
/// 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)
|
||||
{
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
ILibraryManager libraryManager,
|
||||
MediaSegmentUpdateManager mediaSegmentUpdateManager) : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<DetectSegmentsTask> _logger = logger;
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory = loggerFactory;
|
||||
|
||||
private readonly ILibraryManager _libraryManager = libraryManager;
|
||||
|
||||
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task name.
|
||||
/// </summary>
|
||||
public string Name => "Detect Intros and Credits";
|
||||
public string Name => "Detect and Analyze Media Segments";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task category.
|
||||
@ -57,7 +55,7 @@ public class DetectIntrosCreditsTask : IScheduledTask
|
||||
/// <summary>
|
||||
/// Gets the task key.
|
||||
/// </summary>
|
||||
public string Key => "CPBIntroSkipperDetectIntrosCredits";
|
||||
public string Key => "IntroSkipperDetectSegmentsTask";
|
||||
|
||||
/// <summary>
|
||||
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
|
||||
@ -65,7 +63,7 @@ public class DetectIntrosCreditsTask : IScheduledTask
|
||||
/// <param name="progress">Task progress.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_libraryManager is null)
|
||||
{
|
||||
@ -75,25 +73,21 @@ public class DetectIntrosCreditsTask : IScheduledTask
|
||||
// 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);
|
||||
_logger.LogInformation("Automatic Task is {TaskState} and will be canceled.", Entrypoint.AutomaticTaskState);
|
||||
await Entrypoint.CancelAutomaticTaskAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using (ScheduledTaskSemaphore.Acquire(cancellationToken))
|
||||
using (await ScheduledTaskSemaphore.AcquireAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogInformation("Scheduled Task is starting");
|
||||
|
||||
var modes = new List<AnalysisMode> { AnalysisMode.Introduction, AnalysisMode.Credits };
|
||||
|
||||
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
|
||||
modes,
|
||||
_loggerFactory.CreateLogger<DetectIntrosCreditsTask>(),
|
||||
_loggerFactory.CreateLogger<DetectSegmentsTask>(),
|
||||
_loggerFactory,
|
||||
_libraryManager);
|
||||
_libraryManager,
|
||||
_mediaSegmentUpdateManager);
|
||||
|
||||
baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||
|
||||
return Task.CompletedTask;
|
||||
await baseIntroAnalyzer.AnalyzeItemsAsync(progress, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace IntroSkipper.ScheduledTasks;
|
||||
|
||||
@ -14,9 +15,9 @@ internal sealed class ScheduledTaskSemaphore : IDisposable
|
||||
{
|
||||
}
|
||||
|
||||
public static IDisposable Acquire(CancellationToken cancellationToken)
|
||||
public static async Task<IDisposable> AcquireAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_semaphore.Wait(cancellationToken);
|
||||
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new ScheduledTaskSemaphore();
|
||||
}
|
||||
|
||||
|
@ -2,13 +2,15 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using IntroSkipper.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using IntroSkipper.Controllers;
|
||||
using IntroSkipper.Data;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Entities;
|
||||
@ -16,137 +18,129 @@ 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 introduction sequences.
|
||||
/// Commands clients to seek to the end of the intro as soon as they start playing it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="userDataManager">User data manager.</param>
|
||||
/// <param name="sessionManager">Session manager.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public class AutoSkip(
|
||||
namespace IntroSkipper.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically skip past introduction sequences.
|
||||
/// Commands clients to seek to the end of the intro as soon as they start playing it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="userDataManager">User data manager.</param>
|
||||
/// <param name="sessionManager">Session manager.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public sealed class AutoSkip(
|
||||
IUserDataManager userDataManager,
|
||||
ISessionManager sessionManager,
|
||||
ILogger<AutoSkip> logger) : IHostedService, IDisposable
|
||||
{
|
||||
private readonly object _sentSeekCommandLock = new();
|
||||
|
||||
private ILogger<AutoSkip> _logger = logger;
|
||||
private IUserDataManager _userDataManager = userDataManager;
|
||||
private ISessionManager _sessionManager = sessionManager;
|
||||
private Timer _playbackTimer = new(1000);
|
||||
private Dictionary<string, bool> _sentSeekCommand = [];
|
||||
{
|
||||
private readonly IUserDataManager _userDataManager = userDataManager;
|
||||
private readonly ISessionManager _sessionManager = sessionManager;
|
||||
private readonly ILogger<AutoSkip> _logger = logger;
|
||||
private readonly System.Timers.Timer _playbackTimer = new(1000);
|
||||
private readonly ConcurrentDictionary<string, List<Intro>> _sentSeekCommand = [];
|
||||
private PluginConfiguration _config = new();
|
||||
private HashSet<string> _clientList = [];
|
||||
private HashSet<AnalysisMode> _segmentTypes = [];
|
||||
private bool _autoSkipEnabled;
|
||||
|
||||
private void AutoSkipChanged(object? sender, BasePluginConfiguration e)
|
||||
{
|
||||
var configuration = (PluginConfiguration)e;
|
||||
_clientList = [.. configuration.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||
var newState = configuration.AutoSkip || (configuration.SkipButtonVisible && _clientList.Count > 0);
|
||||
_logger.LogDebug("Setting playback timer enabled to {NewState}", newState);
|
||||
_playbackTimer.Enabled = newState;
|
||||
_config = (PluginConfiguration)e;
|
||||
_clientList = [.. _config.ClientList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||
_segmentTypes = [.. _config.TypeList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(Enum.Parse<AnalysisMode>)];
|
||||
_autoSkipEnabled = (_config.AutoSkip || _clientList.Count > 0) && _segmentTypes.Count > 0;
|
||||
_logger.LogDebug("Setting playback timer enabled to {AutoSkipEnabled}", _autoSkipEnabled);
|
||||
_playbackTimer.Enabled = _autoSkipEnabled;
|
||||
}
|
||||
|
||||
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
|
||||
{
|
||||
var itemId = e.Item.Id;
|
||||
var newState = false;
|
||||
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
|
||||
|
||||
// Ignore all events except playback start & end
|
||||
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
|
||||
if (e.SaveReason is not (UserDataSaveReason.PlaybackStart or UserDataSaveReason.PlaybackFinished) || !_autoSkipEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Lookup the session for this item.
|
||||
SessionInfo? session = null;
|
||||
var itemId = e.Item.Id;
|
||||
var session = _sessionManager.Sessions
|
||||
.FirstOrDefault(s => s.UserId == e.UserId && s.NowPlayingItem?.Id == itemId);
|
||||
|
||||
try
|
||||
if (session is null)
|
||||
{
|
||||
foreach (var needle in _sessionManager.Sessions)
|
||||
// Clean up orphaned sessions
|
||||
if (!_sessionManager.Sessions
|
||||
.Where(s => s.UserId == e.UserId && s.NowPlayingItem is null)
|
||||
.Any(s => _sentSeekCommand.TryRemove(s.DeviceId, out _)))
|
||||
{
|
||||
if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
|
||||
{
|
||||
session = needle;
|
||||
break;
|
||||
}
|
||||
_logger.LogInformation("Unable to find active session for item {ItemId}", itemId);
|
||||
}
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
_logger.LogInformation("Unable to find session for {Item}", itemId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session.
|
||||
if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
|
||||
{
|
||||
newState = true;
|
||||
}
|
||||
|
||||
// Reset the seek command state for this device.
|
||||
lock (_sentSeekCommandLock)
|
||||
{
|
||||
var device = session.DeviceId;
|
||||
_logger.LogDebug("Getting intros for session {Session}", device);
|
||||
|
||||
_logger.LogDebug("Resetting seek command state for session {Session}", device);
|
||||
_sentSeekCommand[device] = newState;
|
||||
}
|
||||
bool firstEpisode = _config.SkipFirstEpisode && e.Item.IndexNumber.GetValueOrDefault(-1) == 1;
|
||||
var intros = SkipIntroController.GetIntros(itemId)
|
||||
.Where(i => _segmentTypes.Contains(i.Key) && (!firstEpisode || i.Key != AnalysisMode.Introduction))
|
||||
.Select(i => i.Value)
|
||||
.ToList();
|
||||
|
||||
_sentSeekCommand.AddOrUpdate(device, intros, (_, _) => intros);
|
||||
}
|
||||
|
||||
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
foreach (var session in _sessionManager.Sessions.Where(s => Plugin.Instance!.Configuration.AutoSkip || (Plugin.Instance!.Configuration.SkipButtonVisible && _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase))))
|
||||
foreach (var session in _sessionManager.Sessions.Where(s => _config.AutoSkip || _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
var deviceId = session.DeviceId;
|
||||
var itemId = session.NowPlayingItem.Id;
|
||||
var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
|
||||
|
||||
// Don't send the seek command more than once in the same session.
|
||||
lock (_sentSeekCommandLock)
|
||||
{
|
||||
if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent)
|
||||
{
|
||||
_logger.LogTrace("Already sent seek command for session {Session}", deviceId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert that an intro was detected for this item.
|
||||
if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid)
|
||||
if (!_sentSeekCommand.TryGetValue(deviceId, out var intros))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Seek is unreliable if called at the very start of an episode.
|
||||
var adjustedStart = Math.Max(1, intro.Start + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay);
|
||||
var adjustedEnd = intro.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro;
|
||||
var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
|
||||
|
||||
var currentIntro = intros.FirstOrDefault(i =>
|
||||
position >= Math.Max(1, i.IntroStart + _config.SecondsOfIntroStartToPlay) &&
|
||||
position < i.IntroEnd - 3.0); // 3 seconds before the end of the intro
|
||||
|
||||
if (currentIntro is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var introEnd = currentIntro.IntroEnd;
|
||||
|
||||
intros.Remove(currentIntro);
|
||||
|
||||
// Check if adjacent segment is within the maximum skip range.
|
||||
var maxTimeSkip = _config.MaximumTimeSkip + _config.RemainingSecondsOfIntro;
|
||||
var nextIntro = intros.FirstOrDefault(i => introEnd + maxTimeSkip >= i.IntroStart &&
|
||||
introEnd < i.IntroEnd);
|
||||
|
||||
if (nextIntro is not null)
|
||||
{
|
||||
introEnd = nextIntro.IntroEnd;
|
||||
intros.Remove(nextIntro);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Found segment for session {Session}, removing from list, {Intros} segments remaining", deviceId, intros.Count);
|
||||
|
||||
_logger.LogTrace(
|
||||
"Playback position is {Position}, intro runs from {Start} to {End}",
|
||||
position,
|
||||
adjustedStart,
|
||||
adjustedEnd);
|
||||
|
||||
if (position < adjustedStart || position > adjustedEnd)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
"Playback position is {Position}",
|
||||
position);
|
||||
|
||||
// Notify the user that an introduction is being skipped for them.
|
||||
var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText;
|
||||
var notificationText = _config.AutoSkipNotificationText;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notificationText))
|
||||
{
|
||||
_sessionManager.SendMessageCommand(
|
||||
@ -170,40 +164,20 @@ public class AutoSkip(
|
||||
{
|
||||
Command = PlaystateCommand.Seek,
|
||||
ControllingUserId = session.UserId.ToString(),
|
||||
SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond,
|
||||
SeekPositionTicks = (long)introEnd * TimeSpan.TicksPerSecond,
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
// Flag that we've sent the seek command so that it's not sent repeatedly
|
||||
lock (_sentSeekCommandLock)
|
||||
{
|
||||
_logger.LogTrace("Setting seek command state for session {Session}", deviceId);
|
||||
_sentSeekCommand[deviceId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose.
|
||||
/// Dispose resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protected dispose.
|
||||
/// </summary>
|
||||
/// <param name="disposing">Dispose.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!disposing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_playbackTimer.Stop();
|
||||
_playbackTimer.Dispose();
|
||||
}
|
||||
|
||||
@ -228,6 +202,9 @@ public class AutoSkip(
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
|
||||
Plugin.Instance!.ConfigurationChanged -= AutoSkipChanged;
|
||||
_playbackTimer.Stop();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,233 +0,0 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using IntroSkipper.Configuration;
|
||||
using 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,8 +6,9 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Configuration;
|
||||
using IntroSkipper.Data;
|
||||
using IntroSkipper.Manager;
|
||||
using IntroSkipper.ScheduledTasks;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
@ -16,20 +17,21 @@ using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace IntroSkipper;
|
||||
|
||||
/// <summary>
|
||||
/// Server entrypoint.
|
||||
/// </summary>
|
||||
public sealed class Entrypoint : IHostedService, IDisposable
|
||||
namespace IntroSkipper.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Server entrypoint.
|
||||
/// </summary>
|
||||
public sealed class Entrypoint : IHostedService, IDisposable
|
||||
{
|
||||
private readonly ITaskManager _taskManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<Entrypoint> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager;
|
||||
private readonly HashSet<Guid> _seasonsToAnalyze = [];
|
||||
private readonly Timer _queueTimer;
|
||||
private static readonly ManualResetEventSlim _autoTaskCompletEvent = new(false);
|
||||
private static readonly SemaphoreSlim _analysisSemaphore = new(1, 1);
|
||||
private PluginConfiguration _config;
|
||||
private bool _analyzeAgain;
|
||||
private static CancellationTokenSource? _cancellationTokenSource;
|
||||
@ -41,16 +43,19 @@ public sealed class Entrypoint : IHostedService, IDisposable
|
||||
/// <param name="taskManager">Task manager.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="loggerFactory">Logger factory.</param>
|
||||
/// <param name="mediaSegmentUpdateManager">MediaSegment Update Manager.</param>
|
||||
public Entrypoint(
|
||||
ILibraryManager libraryManager,
|
||||
ITaskManager taskManager,
|
||||
ILogger<Entrypoint> logger,
|
||||
ILoggerFactory loggerFactory)
|
||||
ILoggerFactory loggerFactory,
|
||||
MediaSegmentUpdateManager mediaSegmentUpdateManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_taskManager = taskManager;
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
_mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
||||
|
||||
_config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
_queueTimer = new Timer(
|
||||
@ -63,42 +68,26 @@ public sealed class Entrypoint : IHostedService, IDisposable
|
||||
/// <summary>
|
||||
/// Gets State of the automatic task.
|
||||
/// </summary>
|
||||
public static TaskState AutomaticTaskState
|
||||
public static TaskState AutomaticTaskState => _cancellationTokenSource switch
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cancellationTokenSource is not null)
|
||||
{
|
||||
return _cancellationTokenSource.IsCancellationRequested
|
||||
? TaskState.Cancelling
|
||||
: TaskState.Running;
|
||||
}
|
||||
|
||||
return TaskState.Idle;
|
||||
}
|
||||
}
|
||||
null => TaskState.Idle,
|
||||
{ IsCancellationRequested: true } => TaskState.Cancelling,
|
||||
_ => TaskState.Running
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_libraryManager.ItemAdded += OnItemAdded;
|
||||
_libraryManager.ItemUpdated += OnItemModified;
|
||||
_libraryManager.ItemAdded += OnItemChanged;
|
||||
_libraryManager.ItemUpdated += OnItemChanged;
|
||||
_taskManager.TaskCompleted += OnLibraryRefresh;
|
||||
Plugin.Instance!.ConfigurationChanged += OnSettingsChanged;
|
||||
|
||||
FFmpegWrapper.Logger = _logger;
|
||||
|
||||
try
|
||||
{
|
||||
// Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible
|
||||
_logger.LogInformation("Running startup enqueue");
|
||||
var queueManager = new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager);
|
||||
queueManager?.GetMediaItems();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("Unable to run startup enqueue: {Exception}", ex);
|
||||
}
|
||||
new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager).GetMediaItems();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@ -106,75 +95,33 @@ public sealed class Entrypoint : IHostedService, IDisposable
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_libraryManager.ItemAdded -= OnItemAdded;
|
||||
_libraryManager.ItemUpdated -= OnItemModified;
|
||||
_libraryManager.ItemAdded -= OnItemChanged;
|
||||
_libraryManager.ItemUpdated -= OnItemChanged;
|
||||
_taskManager.TaskCompleted -= OnLibraryRefresh;
|
||||
Plugin.Instance!.ConfigurationChanged -= OnSettingsChanged;
|
||||
|
||||
// Stop the timer
|
||||
_queueTimer.Change(Timeout.Infinite, 0);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Disclose source for inspiration
|
||||
// Implementation based on the principles of jellyfin-plugin-media-analyzer:
|
||||
// https://github.com/endrl/jellyfin-plugin-media-analyzer
|
||||
|
||||
/// <summary>
|
||||
/// Library item was added.
|
||||
/// </summary>
|
||||
/// <param name="sender">The sending entity.</param>
|
||||
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
|
||||
private void OnItemAdded(object? sender, ItemChangeEventArgs itemChangeEventArgs)
|
||||
private void OnItemChanged(object? sender, ItemChangeEventArgs itemChangeEventArgs)
|
||||
{
|
||||
// Don't do anything if auto detection is disabled
|
||||
if (!_config.AutoDetectIntros && !_config.AutoDetectCredits)
|
||||
if (_config.AutoDetectIntros &&
|
||||
itemChangeEventArgs.Item is { LocationType: not LocationType.Virtual } item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Guid? id = item is Episode episode ? episode.SeasonId : (item is Movie movie ? movie.Id : null);
|
||||
|
||||
// Don't do anything if it's not a supported media type
|
||||
if (itemChangeEventArgs.Item is not Episode episode)
|
||||
if (id.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_seasonsToAnalyze.Add(episode.SeasonId);
|
||||
|
||||
_seasonsToAnalyze.Add(id.Value);
|
||||
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>
|
||||
@ -184,34 +131,19 @@ public sealed class Entrypoint : IHostedService, IDisposable
|
||||
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
|
||||
private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs)
|
||||
{
|
||||
// Don't do anything if auto detection is disabled
|
||||
if (!_config.AutoDetectIntros && !_config.AutoDetectCredits)
|
||||
if (_config.AutoDetectIntros &&
|
||||
eventArgs.Result is { Key: "RefreshLibrary", Status: TaskCompletionStatus.Completed } &&
|
||||
AutomaticTaskState != TaskState.Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var result = eventArgs.Result;
|
||||
|
||||
if (result.Key != "RefreshLibrary")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Status != TaskCompletionStatus.Completed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Unless user initiated, this is likely an overlap
|
||||
if (AutomaticTaskState == TaskState.Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StartTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, BasePluginConfiguration e) => _config = (PluginConfiguration)e;
|
||||
private void OnSettingsChanged(object? sender, BasePluginConfiguration e)
|
||||
{
|
||||
_config = (PluginConfiguration)e;
|
||||
Plugin.Instance!.AnalyzeAgain = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start timer to debounce analyzing.
|
||||
@ -225,90 +157,72 @@ public sealed class Entrypoint : IHostedService, IDisposable
|
||||
else if (AutomaticTaskState == TaskState.Idle)
|
||||
{
|
||||
_logger.LogDebug("Media Library changed, analyzis will start soon!");
|
||||
_queueTimer.Change(TimeSpan.FromMilliseconds(20000), Timeout.InfiniteTimeSpan);
|
||||
_queueTimer.Change(TimeSpan.FromSeconds(60), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for timer callback to be completed.
|
||||
/// </summary>
|
||||
private void OnTimerCallback(object? state)
|
||||
private void OnTimerCallback(object? state) =>
|
||||
_ = RunAnalysisAsync();
|
||||
|
||||
private async Task RunAnalysisAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
PerformAnalysis();
|
||||
await PerformAnalysisAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Automatic Analysis task cancelled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in PerformAnalysis");
|
||||
_logger.LogError(ex, "Error in RunAnalysisAsync");
|
||||
}
|
||||
|
||||
// Clean up
|
||||
_cancellationTokenSource = null;
|
||||
_autoTaskCompletEvent.Set();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for timer to be completed.
|
||||
/// </summary>
|
||||
private void PerformAnalysis()
|
||||
private async Task PerformAnalysisAsync()
|
||||
{
|
||||
await _analysisSemaphore.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Initiate automatic analysis task.");
|
||||
_autoTaskCompletEvent.Reset();
|
||||
|
||||
using (_cancellationTokenSource = new CancellationTokenSource())
|
||||
using (ScheduledTaskSemaphore.Acquire(_cancellationTokenSource.Token))
|
||||
using (await ScheduledTaskSemaphore.AcquireAsync(_cancellationTokenSource.Token).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogInformation("Initiating automatic analysis task");
|
||||
var seasonIds = new HashSet<Guid>(_seasonsToAnalyze);
|
||||
_seasonsToAnalyze.Clear();
|
||||
|
||||
_analyzeAgain = false;
|
||||
var progress = new Progress<double>();
|
||||
var modes = new List<AnalysisMode>();
|
||||
var tasklogger = _loggerFactory.CreateLogger("DefaultLogger");
|
||||
|
||||
if (_config.AutoDetectIntros)
|
||||
{
|
||||
modes.Add(AnalysisMode.Introduction);
|
||||
tasklogger = _loggerFactory.CreateLogger<DetectIntrosTask>();
|
||||
}
|
||||
var analyzer = new BaseItemAnalyzerTask(_loggerFactory.CreateLogger<Entrypoint>(), _loggerFactory, _libraryManager, _mediaSegmentUpdateManager);
|
||||
await analyzer.AnalyzeItemsAsync(new Progress<double>(), _cancellationTokenSource.Token, seasonIds).ConfigureAwait(false);
|
||||
|
||||
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)
|
||||
{
|
||||
_logger.LogInformation("Analyzing ended, but we need to analyze again!");
|
||||
StartTimer();
|
||||
_queueTimer.Change(TimeSpan.FromSeconds(60), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_analysisSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Method to cancel the automatic task.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public static void CancelAutomaticTask(CancellationToken cancellationToken)
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
public static async Task CancelAutomaticTaskAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested)
|
||||
if (_cancellationTokenSource is { IsCancellationRequested: false })
|
||||
{
|
||||
try
|
||||
{
|
||||
_cancellationTokenSource.Cancel();
|
||||
await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
@ -316,7 +230,7 @@ public sealed class Entrypoint : IHostedService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
_autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal
|
||||
await _analysisSemaphore.WaitAsync(TimeSpan.FromSeconds(60), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@ -324,6 +238,7 @@ public sealed class Entrypoint : IHostedService, IDisposable
|
||||
{
|
||||
_queueTimer.Dispose();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_autoTaskCompletEvent.Dispose();
|
||||
_analysisSemaphore.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
20
README.md
20
README.md
@ -9,6 +9,9 @@
|
||||
</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)
|
||||
<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>
|
||||
|
||||
## Manifest URL (All Jellyfin Versions)
|
||||
@ -19,11 +22,11 @@ https://manifest.intro-skipper.org/manifest.json
|
||||
|
||||
## System requirements
|
||||
|
||||
* Jellyfin 10.9.11 (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.9.z container: preinstalled
|
||||
* `linuxserver/jellyfin` 10.9.z container: preinstalled
|
||||
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg6` package
|
||||
* Jellyfin 10.10.3 (or newer)
|
||||
* Jellyfin's [fork](https://github.com/jellyfin/jellyfin-ffmpeg) of `ffmpeg` must be installed, version `7.0.2-5` or newer
|
||||
* `jellyfin/jellyfin` 10.10.z container: preinstalled
|
||||
* `linuxserver/jellyfin` 10.10.z container: preinstalled
|
||||
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg7` package
|
||||
* MacOS native installs: build ffmpeg with chromaprint support ([instructions](https://github.com/intro-skipper/intro-skipper/wiki/Custom-FFMPEG-(MacOS)))
|
||||
|
||||
## Limitations
|
||||
@ -35,10 +38,17 @@ https://manifest.intro-skipper.org/manifest.json
|
||||
## [Detection types](https://github.com/intro-skipper/intro-skipper/wiki#detection-types)
|
||||
|
||||
## [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)
|
||||
|
||||
## [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)
|
||||
|
||||
|
@ -1 +1 @@
|
||||
10.9
|
||||
10.10
|
16
docs/edl.md
16
docs/edl.md
@ -1,16 +0,0 @@
|
||||
# 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,20 +9,12 @@
|
||||
"imageUrl": "https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/images/logo.png",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.10.9.2",
|
||||
"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.9.11.0",
|
||||
"sourceUrl": "https://github.com/intro-skipper/intro-skipper/releases/download/10.9/v1.10.9.2/intro-skipper-v1.10.9.2.zip",
|
||||
"checksum": "71a819c0f5657d14e7181e409455b5d8",
|
||||
"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"
|
||||
"version": "1.10.10.11",
|
||||
"changelog": "- See the full changelog at [GitHub](https://github.com/intro-skipper/intro-skipper/releases/tag/10.10/v1.10.10.11)\n",
|
||||
"targetAbi": "10.10.3.0",
|
||||
"sourceUrl": "https://github.com/intro-skipper/intro-skipper/releases/download/10.10/v1.10.10.11/intro-skipper-v1.10.10.11.zip",
|
||||
"checksum": "30a71fd3996e0fbe9076371539b1ca56",
|
||||
"timestamp": "2024-11-25T17:07:33Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
226
webui.patch
226
webui.patch
@ -1,226 +0,0 @@
|
||||
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