Compare commits
138 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 | ||
|
eb45e1f73a | ||
|
a81b367ffe | ||
|
f07134cc55 | ||
|
49429b9ca4 | ||
|
76c9d8013f | ||
|
fbe88e488d | ||
|
3c02426532 |
@ -192,3 +192,5 @@ csharp_space_between_method_call_empty_parameter_list_parentheses = false
|
||||
# Wrapping preferences
|
||||
csharp_preserve_single_line_statements = true
|
||||
csharp_preserve_single_line_blocks = true
|
||||
|
||||
file_header_template = Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>\nSPDX-License-Identifier: GPL-3.0-only.
|
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:
|
||||
|
67
.github/workflows/build.yml
vendored
67
.github/workflows/build.yml
vendored
@ -2,15 +2,8 @@ name: "Build Plugin"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
paths-ignore:
|
||||
- "**/README.md"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- "docs/**"
|
||||
- "images/**"
|
||||
- "manifest.json"
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
branches:
|
||||
- '*' # Triggers on any branch push
|
||||
paths-ignore:
|
||||
- "**/README.md"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
@ -39,6 +32,22 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Read version from VERSION.txt
|
||||
id: read-version
|
||||
run: |
|
||||
MAIN_VERSION=$(cat VERSION.txt)
|
||||
echo "MAIN_VERSION=${MAIN_VERSION}"
|
||||
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Check for BETA file
|
||||
id: check-beta
|
||||
run: |
|
||||
if [ -f "BETA" ]; then
|
||||
echo "IS_BETA=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "IS_BETA=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
@ -51,17 +60,25 @@ jobs:
|
||||
|
||||
- name: Minify HTML
|
||||
run: |
|
||||
npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --collapse-inline-tag-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html
|
||||
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m
|
||||
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -c -m
|
||||
npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o IntroSkipper/Configuration/configPage.html IntroSkipper/Configuration/configPage.html
|
||||
npx terser IntroSkipper/Configuration/inject.js -o IntroSkipper/Configuration/inject.js -c -m
|
||||
npx terser IntroSkipper/Configuration/visualizer.js -o IntroSkipper/Configuration/visualizer.js -c -m
|
||||
|
||||
- name: Restore Beta dependencies
|
||||
if: ${{env.IS_BETA == 'true' }}
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
|
||||
dotnet tool install --global dotnet-outdated-tool
|
||||
dotnet outdated -pre Always -u -inc Jellyfin
|
||||
|
||||
- name: Restore dependencies
|
||||
if: ${{env.IS_BETA == 'false' }}
|
||||
run: dotnet restore
|
||||
|
||||
- name: Embed version info
|
||||
run: |
|
||||
GITHUB_SHA=${{ github.sha }}
|
||||
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" ConfusedPolarBear.Plugin.IntroSkipper/Helper/Commit.cs
|
||||
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" IntroSkipper/Helper/Commit.cs
|
||||
|
||||
- name: Retrieve commit identification
|
||||
run: |
|
||||
@ -73,29 +90,19 @@ jobs:
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.GIT_HASH }}.dll
|
||||
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4.3.6
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.SANITIZED_BRANCH_NAME }}.dll
|
||||
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||
retention-days: 7
|
||||
name: IntroSkipper-${{ env.GIT_HASH }}.dll
|
||||
path: IntroSkipper/bin/Debug/net8.0/IntroSkipper.dll
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create archive
|
||||
if: github.event_name != 'pull_request'
|
||||
run: zip -j "intro-skipper-${{ env.GIT_HASH }}.zip" ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||
if: startsWith(github.ref_name, '10.') # only do a preview release on 10.x branches
|
||||
run: zip -j "intro-skipper-${{ env.GIT_HASH }}.zip" IntroSkipper/bin/Debug/net8.0/IntroSkipper.dll
|
||||
|
||||
- name: Create/replace the preview release and upload artifacts
|
||||
if: github.event_name != 'pull_request'
|
||||
if: startsWith(github.ref_name, '10.') # only do a preview release on 10.x branches
|
||||
run: |
|
||||
gh release delete '10.9/preview' --cleanup-tag --yes || true
|
||||
gh release create '10.9/preview' "intro-skipper-${{ env.GIT_HASH }}.zip" --prerelease --title "intro-skipper-${{ env.GIT_HASH }}" --notes "This is a prerelease version."
|
||||
gh release delete "${{ env.MAIN_VERSION }}/preview" --cleanup-tag --yes || true
|
||||
gh release create "${{ env.MAIN_VERSION }}/preview" "intro-skipper-${{ env.GIT_HASH }}.zip" --prerelease --title "intro-skipper-${{ env.GIT_HASH }}" --notes "This is a prerelease version." --target ${{ env.MAIN_VERSION }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
33
.github/workflows/codeql.yml
vendored
33
.github/workflows/codeql.yml
vendored
@ -2,15 +2,8 @@ name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths-ignore:
|
||||
- "**/README.md"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- "docs/**"
|
||||
- "images/**"
|
||||
- "manifest.json"
|
||||
pull_request:
|
||||
branches: [master]
|
||||
branches:
|
||||
- '*' # Triggers on any branch push
|
||||
paths-ignore:
|
||||
- "**/README.md"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
@ -36,25 +29,39 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check for BETA file
|
||||
id: check-beta
|
||||
run: |
|
||||
if [ -f "BETA" ]; then
|
||||
echo "IS_BETA=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "IS_BETA=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Restore Beta dependencies
|
||||
if: ${{env.IS_BETA == 'true' }}
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
|
||||
dotnet tool install --global dotnet-outdated-tool
|
||||
dotnet outdated -pre Always -u -inc Jellyfin
|
||||
|
||||
- name: Restore dependencies
|
||||
if: ${{env.IS_BETA == 'false' }}
|
||||
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
|
||||
|
42
.github/workflows/release.yml
vendored
42
.github/workflows/release.yml
vendored
@ -5,6 +5,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -13,6 +14,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Read version from VERSION.txt
|
||||
id: read-version
|
||||
run: |
|
||||
MAIN_VERSION=$(cat VERSION.txt)
|
||||
echo "MAIN_VERSION=${MAIN_VERSION}"
|
||||
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Check for BETA file
|
||||
id: check-beta
|
||||
run: |
|
||||
if [ -f "BETA" ]; then
|
||||
echo "IS_BETA=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "IS_BETA=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
@ -25,11 +42,19 @@ jobs:
|
||||
|
||||
- name: Minify HTML
|
||||
run: |
|
||||
npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --collapse-inline-tag-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html
|
||||
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m
|
||||
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -c -m
|
||||
npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o IntroSkipper/Configuration/configPage.html IntroSkipper/Configuration/configPage.html
|
||||
npx terser IntroSkipper/Configuration/inject.js -o IntroSkipper/Configuration/inject.js -c -m
|
||||
npx terser IntroSkipper/Configuration/visualizer.js -o IntroSkipper/Configuration/visualizer.js -c -m
|
||||
|
||||
- name: Restore Beta dependencies
|
||||
if: ${{env.IS_BETA == 'true' }}
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
|
||||
dotnet tool install --global dotnet-outdated-tool
|
||||
dotnet outdated -pre Always -u -inc Jellyfin
|
||||
|
||||
- name: Restore dependencies
|
||||
if: ${{env.IS_BETA == 'false' }}
|
||||
run: dotnet restore
|
||||
|
||||
- name: Run update version
|
||||
@ -40,23 +65,23 @@ jobs:
|
||||
- name: Embed version info
|
||||
run: |
|
||||
GITHUB_SHA=${{ github.sha }}
|
||||
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" ConfusedPolarBear.Plugin.IntroSkipper/Helper/Commit.cs
|
||||
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" IntroSkipper/Helper/Commit.cs
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Create archive
|
||||
run: zip -j "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" ConfusedPolarBear.Plugin.IntroSkipper/bin/Release/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||
run: zip -j "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" IntroSkipper/bin/Release/net8.0/IntroSkipper.dll
|
||||
|
||||
- name: Remove old release if exits
|
||||
if: ${{ github.repository == 'intro-skipper/intro-skipper-test' }}
|
||||
run: gh release delete "10.9/v${{ env.NEW_FILE_VERSION }}" --cleanup-tag --yes || true
|
||||
run: gh release delete "${{ env.MAIN_VERSION }}/v${{ env.NEW_FILE_VERSION }}" --cleanup-tag --yes || true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create new release with tag
|
||||
if: github.event_name != 'pull_request'
|
||||
run: gh release create "10.9/v${{ env.NEW_FILE_VERSION }}" "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" --title "v${{ env.NEW_FILE_VERSION }}" --latest --generate-notes
|
||||
run: gh release create "${{ env.MAIN_VERSION }}/v${{ env.NEW_FILE_VERSION }}" "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" --title "v${{ env.NEW_FILE_VERSION }}" --latest --generate-notes --target ${{ env.MAIN_VERSION }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@ -66,12 +91,13 @@ jobs:
|
||||
task-type: "updateManifest"
|
||||
env:
|
||||
GITHUB_REPO_VISIBILITY: ${{ github.event.repository.visibility }}
|
||||
MAIN_VERSION: ${{ env.MAIN_VERSION }}
|
||||
|
||||
- name: Commit changes
|
||||
if: success()
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md manifest.json ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj .github/ISSUE_TEMPLATE/bug_report_form.yml
|
||||
git add README.md manifest.json IntroSkipper/IntroSkipper.csproj .github/ISSUE_TEMPLATE/bug_report_form.yml
|
||||
git commit -m "release v${{ env.NEW_FILE_VERSION }}"
|
||||
git push
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,3 +8,6 @@ docker/dist
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
|
||||
# JetBrains Rider
|
||||
.idea/
|
||||
|
11
.vscode/extensions.json
vendored
Normal file
11
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-dotnettools.csharp",
|
||||
"editorconfig.editorconfig",
|
||||
"github.vscode-github-actions",
|
||||
"ms-dotnettools.vscode-dotnet-runtime",
|
||||
"ms-dotnettools.csdevkit",
|
||||
"eamodio.gitlens"
|
||||
],
|
||||
"unwantedRecommendations": []
|
||||
}
|
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 4,
|
||||
"files.encoding": "utf8",
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"editor.rulers": [],
|
||||
"dotnet.defaultSolution": "IntroSkipper.sln"
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
using System;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using Xunit;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.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));
|
||||
}
|
||||
}
|
@ -1,131 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.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)
|
||||
{
|
||||
var modifiedIntros = new Dictionary<Guid, Segment>();
|
||||
|
||||
foreach (var episode in episodes)
|
||||
{
|
||||
_logger.LogTrace("Adjusting introduction end time for {Name} ({Id})", episode.Name, episode.EpisodeId);
|
||||
|
||||
if (!originalIntros.TryGetValue(episode.EpisodeId, out var originalIntro))
|
||||
{
|
||||
_logger.LogTrace("{Name} does not have an intro", episode.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var adjustedIntro = AdjustIntroForEpisode(episode, originalIntro, mode);
|
||||
modifiedIntros[episode.EpisodeId] = adjustedIntro;
|
||||
}
|
||||
|
||||
return modifiedIntros;
|
||||
}
|
||||
|
||||
private Segment AdjustIntroForEpisode(QueuedEpisode episode, Segment originalIntro, AnalysisMode mode)
|
||||
{
|
||||
var chapters = GetChaptersWithVirtualEnd(episode);
|
||||
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));
|
||||
|
||||
_logger.LogTrace("{Name} original intro: {Start} - {End}", episode.Name, originalIntro.Start, originalIntro.End);
|
||||
|
||||
if (!AdjustIntroBasedOnChapters(episode, chapters, adjustedIntro, originalIntroStart, originalIntroEnd)
|
||||
&& mode == AnalysisMode.Introduction)
|
||||
{
|
||||
AdjustIntroBasedOnSilence(episode, adjustedIntro, originalIntroEnd);
|
||||
}
|
||||
|
||||
return adjustedIntro;
|
||||
}
|
||||
|
||||
private static List<ChapterInfo> GetChaptersWithVirtualEnd(QueuedEpisode episode)
|
||||
{
|
||||
var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? [];
|
||||
chapters.Add(new ChapterInfo { StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks });
|
||||
return chapters;
|
||||
}
|
||||
|
||||
private bool AdjustIntroBasedOnChapters(QueuedEpisode episode, List<ChapterInfo> chapters, Segment adjustedIntro, TimeRange originalIntroStart, TimeRange originalIntroEnd)
|
||||
{
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
var chapterStartSeconds = TimeSpan.FromTicks(chapter.StartPositionTicks).TotalSeconds;
|
||||
|
||||
if (originalIntroStart.Start < chapterStartSeconds && chapterStartSeconds < originalIntroStart.End)
|
||||
{
|
||||
adjustedIntro.Start = chapterStartSeconds;
|
||||
_logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, chapterStartSeconds);
|
||||
}
|
||||
|
||||
if (originalIntroEnd.Start < chapterStartSeconds && chapterStartSeconds < originalIntroEnd.End)
|
||||
{
|
||||
adjustedIntro.End = chapterStartSeconds;
|
||||
_logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, chapterStartSeconds);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,207 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
private readonly TimeSpan _maximumError = new(0, 0, 4);
|
||||
|
||||
private readonly ILogger<BlackFrameAnalyzer> _logger;
|
||||
|
||||
private readonly int _minimumCreditsDuration;
|
||||
|
||||
private readonly int _maximumCreditsDuration;
|
||||
|
||||
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 = 2 * config.MaximumCreditsDuration;
|
||||
_blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage;
|
||||
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<QueuedEpisode> AnalyzeMediaFiles(
|
||||
IReadOnlyList<QueuedEpisode> analysisQueue,
|
||||
AnalysisMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (mode != AnalysisMode.Credits)
|
||||
{
|
||||
throw new NotImplementedException("mode must equal Credits");
|
||||
}
|
||||
|
||||
var creditTimes = new Dictionary<Guid, Segment>();
|
||||
|
||||
var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue);
|
||||
|
||||
bool isFirstEpisode = true;
|
||||
|
||||
double searchStart = _minimumCreditsDuration;
|
||||
|
||||
var searchDistance = 2 * _minimumCreditsDuration;
|
||||
|
||||
foreach (var episode in episodeAnalysisQueue.Where(e => !e.State.IsAnalyzed(mode)))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Pre-check to find reasonable starting point.
|
||||
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 > _maximumCreditsDuration)
|
||||
{
|
||||
searchStart = _maximumCreditsDuration;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchStart == _minimumCreditsDuration) // Skip if no black frames were found
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
isFirstEpisode = false;
|
||||
}
|
||||
|
||||
var credit = AnalyzeMediaFile(
|
||||
episode,
|
||||
searchStart,
|
||||
searchDistance,
|
||||
_blackFrameMinimumPercentage);
|
||||
|
||||
if (credit is null)
|
||||
{
|
||||
// If no credits were found, reset the first-episode search logic for the next episode in the sequence.
|
||||
searchStart = _minimumCreditsDuration;
|
||||
isFirstEpisode = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
searchStart = episode.Duration - credit.Start + (0.5 * searchDistance);
|
||||
|
||||
creditTimes.Add(episode.EpisodeId, credit);
|
||||
episode.State.SetAnalyzed(mode, true);
|
||||
}
|
||||
|
||||
var analyzerHelper = new AnalyzerHelper(_logger);
|
||||
creditTimes = analyzerHelper.AdjustIntroTimes(analysisQueue, creditTimes, mode);
|
||||
|
||||
Plugin.Instance!.UpdateTimestamps(creditTimes, mode);
|
||||
|
||||
return episodeAnalysisQueue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes an individual media file. Only public because of unit tests.
|
||||
/// </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)
|
||||
{
|
||||
// Start by analyzing the last N minutes of the file.
|
||||
var upperLimit = searchStart;
|
||||
var lowerLimit = Math.Max(searchStart - searchDistance, _minimumCreditsDuration);
|
||||
var start = TimeSpan.FromSeconds(upperLimit);
|
||||
var end = TimeSpan.FromSeconds(lowerLimit);
|
||||
var firstFrameTime = 0.0;
|
||||
|
||||
// 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)
|
||||
{
|
||||
// Analyze the middle two seconds from the current bisected range
|
||||
var midpoint = (start + end) / 2;
|
||||
var scanTime = episode.Duration - midpoint.TotalSeconds;
|
||||
var tr = new TimeRange(scanTime, scanTime + 2);
|
||||
|
||||
_logger.LogTrace(
|
||||
"{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]",
|
||||
episode.Name,
|
||||
episode.Duration,
|
||||
start,
|
||||
end,
|
||||
tr.Start,
|
||||
tr.End);
|
||||
|
||||
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum);
|
||||
_logger.LogTrace(
|
||||
"{Episode} at {Start} has {Count} black frames",
|
||||
episode.Name,
|
||||
tr.Start,
|
||||
frames.Length);
|
||||
|
||||
if (frames.Length == 0)
|
||||
{
|
||||
// Since no black frames were found, slide the range closer to the end
|
||||
start = midpoint - TimeSpan.FromSeconds(2);
|
||||
|
||||
if (midpoint - TimeSpan.FromSeconds(lowerLimit) < _maximumError)
|
||||
{
|
||||
lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), _minimumCreditsDuration);
|
||||
|
||||
// Reset end for a new search with the increased duration
|
||||
end = TimeSpan.FromSeconds(lowerLimit);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Some black frames were found, slide the range closer to the start
|
||||
end = midpoint;
|
||||
firstFrameTime = frames[0].Time + scanTime;
|
||||
|
||||
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError)
|
||||
{
|
||||
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), _maximumCreditsDuration);
|
||||
|
||||
// Reset start for a new search with the increased duration
|
||||
start = TimeSpan.FromSeconds(upperLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstFrameTime > 0)
|
||||
{
|
||||
return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.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,28 +0,0 @@
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// User interface configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="UserInterfaceConfiguration"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="visible">Skip button visibility.</param>
|
||||
/// <param name="introText">Skip button intro text.</param>
|
||||
/// <param name="creditsText">Skip button end credits text.</param>
|
||||
public class UserInterfaceConfiguration(bool visible, string introText, string creditsText)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to show the skip intro button.
|
||||
/// </summary>
|
||||
public bool SkipButtonVisible { get; set; } = visible;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text to display in the skip intro button in introduction mode.
|
||||
/// </summary>
|
||||
public string SkipButtonIntroText { get; set; } = introText;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text to display in the skip intro button in end credits mode.
|
||||
/// </summary>
|
||||
public string SkipButtonEndCreditsText { get; set; } = creditsText;
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -1,461 +0,0 @@
|
||||
const introSkipper = {
|
||||
originalFetch: window.fetch.bind(window),
|
||||
d: msg => console.debug("[intro skipper] ", msg),
|
||||
setup() {
|
||||
this.initializeState();
|
||||
this.initializeObserver();
|
||||
this.currentOption = localStorage.getItem('introskipperOption') || 'Show Button';
|
||||
document.addEventListener("viewshow", this.viewShow.bind(this));
|
||||
window.fetch = this.fetchWrapper.bind(this);
|
||||
this.videoPositionChanged = this.videoPositionChanged.bind(this);
|
||||
this.handleEscapeKey = this.handleEscapeKey.bind(this);
|
||||
this.d("Registered hooks");
|
||||
},
|
||||
initializeState() {
|
||||
Object.assign(this, { allowEnter: true, skipSegments: {}, videoPlayer: null, skipButton: null, osdElement: null, skipperData: null, currentEpisodeId: null, injectMetadata: false });
|
||||
},
|
||||
initializeObserver() {
|
||||
this.observer = new MutationObserver(mutations => {
|
||||
const actionSheet = mutations[mutations.length - 1].target.querySelector('.actionSheet');
|
||||
if (actionSheet && !actionSheet.querySelector(`[data-id="${'introskipperMenu'}"]`)) this.injectIntroSkipperOptions(actionSheet);
|
||||
});
|
||||
},
|
||||
/** Wrapper around fetch() that retrieves skip segments for the currently playing item or metadata. */
|
||||
async fetchWrapper(resource, options) {
|
||||
const response = await this.originalFetch(resource, options);
|
||||
this.processResource(resource);
|
||||
return response;
|
||||
},
|
||||
async processResource(resource) {
|
||||
try {
|
||||
const url = new URL(resource);
|
||||
const pathname = url.pathname;
|
||||
if (pathname.includes("/PlaybackInfo")) {
|
||||
this.d(`Retrieving skip segments from URL ${pathname}`);
|
||||
const pathArr = pathname.split("/");
|
||||
const id = pathArr[pathArr.indexOf("Items") + 1] || pathArr[3];
|
||||
this.skipSegments = await this.secureFetch(`Episode/${id}/IntroSkipperSegments`);
|
||||
this.d("Retrieved skip segments", this.skipSegments);
|
||||
} else if (this.injectMetadata && pathname.includes("/MetadataEditor")) {
|
||||
this.d(`Metadata editor detected, URL ${pathname}`);
|
||||
const pathArr = pathname.split("/");
|
||||
this.currentEpisodeId = pathArr[pathArr.indexOf("Items") + 1] || pathArr[3];
|
||||
this.skipperData = await this.secureFetch(`Episode/${this.currentEpisodeId}/Timestamps`);
|
||||
if (this.skipperData) {
|
||||
requestAnimationFrame(() => {
|
||||
const metadataFormFields = document.querySelector('.metadataFormFields');
|
||||
metadataFormFields && this.injectSkipperFields(metadataFormFields);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error processing", resource, e);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Event handler that runs whenever the current view changes.
|
||||
* Used to detect the start of video playback.
|
||||
*/
|
||||
viewShow() {
|
||||
const location = window.location.hash;
|
||||
this.d(`Location changed to ${location}`);
|
||||
this.allowEnter = true;
|
||||
this.injectMetadata = /#\/(tv|details|home|search)/.test(location);
|
||||
if (location === "#/video") {
|
||||
this.injectCss();
|
||||
this.injectButton();
|
||||
this.videoPlayer = document.querySelector("video");
|
||||
if (this.videoPlayer) {
|
||||
this.d("Hooking video timeupdate");
|
||||
this.videoPlayer.addEventListener("timeupdate", this.videoPositionChanged);
|
||||
this.osdElement = document.querySelector("div.videoOsdBottom")
|
||||
this.observer.observe(document.body, { childList: true, subtree: false });
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Injects the CSS used by the skip intro button.
|
||||
* Calling this function is a no-op if the CSS has already been injected.
|
||||
*/
|
||||
injectCss() {
|
||||
if (document.querySelector("style#introSkipperCss")) {
|
||||
this.d("CSS already added");
|
||||
return;
|
||||
}
|
||||
this.d("Adding CSS");
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.id = "introSkipperCss";
|
||||
styleElement.textContent = `
|
||||
:root {
|
||||
--rounding: 4px;
|
||||
--accent: 0, 164, 220;
|
||||
}
|
||||
#skipIntro.upNextContainer {
|
||||
width: unset;
|
||||
margin: unset;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
`;
|
||||
document.querySelector("head").appendChild(styleElement);
|
||||
},
|
||||
/**
|
||||
* Inject the skip intro button into the video player.
|
||||
* Calling this function is a no-op if the CSS has already been injected.
|
||||
*/
|
||||
async injectButton() {
|
||||
// Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
|
||||
const preExistingButton = document.querySelector("div.skipIntro");
|
||||
if (preExistingButton) {
|
||||
preExistingButton.style.display = "none";
|
||||
}
|
||||
if (document.querySelector(".btnSkipIntro.injected")) {
|
||||
this.d("Button already added");
|
||||
this.skipButton = document.querySelector("#skipIntro");
|
||||
return;
|
||||
}
|
||||
const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
|
||||
if (!config.SkipButtonVisible) {
|
||||
this.d("Not adding button: not visible");
|
||||
return;
|
||||
}
|
||||
this.d("Adding button");
|
||||
this.skipButton = document.createElement("div");
|
||||
this.skipButton.id = "skipIntro";
|
||||
this.skipButton.classList.add("hide", "upNextContainer");
|
||||
this.skipButton.addEventListener("click", this.doSkip.bind(this));
|
||||
this.skipButton.addEventListener("keydown", this.eventHandler.bind(this));
|
||||
this.skipButton.innerHTML = `
|
||||
<button is="emby-button" type="button" class="btnSkipIntro injected">
|
||||
<span id="btnSkipSegmentText"></span>
|
||||
<span class="material-icons skip_next"></span>
|
||||
</button>
|
||||
`;
|
||||
this.skipButton.dataset.Introduction = config.SkipButtonIntroText;
|
||||
this.skipButton.dataset.Credits = config.SkipButtonEndCreditsText;
|
||||
const controls = document.querySelector("div#videoOsdPage");
|
||||
controls.appendChild(this.skipButton);
|
||||
},
|
||||
/** Tests if the OSD controls are visible. */
|
||||
osdVisible() {
|
||||
return this.osdElement ? !this.osdElement.classList.contains("hide") : false;
|
||||
},
|
||||
/** Get the currently playing skippable segment. */
|
||||
getCurrentSegment(position) {
|
||||
for (const [key, segment] of Object.entries(this.skipSegments)) {
|
||||
if ((position > segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt - 1) ||
|
||||
(this.osdVisible() && position > segment.IntroStart && position < segment.IntroEnd - 1)) {
|
||||
segment.SegmentType = key;
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
return { SegmentType: "None" };
|
||||
},
|
||||
overrideBlur(button) {
|
||||
if (!button.originalBlur) {
|
||||
button.originalBlur = button.blur;
|
||||
button.blur = function() {
|
||||
if (!this.contains(document.activeElement)) {
|
||||
this.originalBlur();
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
/** Playback position changed, check if the skip button needs to be displayed. */
|
||||
videoPositionChanged() {
|
||||
if (!this.skipButton) return;
|
||||
const embyButton = this.skipButton.querySelector(".emby-button");
|
||||
const segmentType = this.getCurrentSegment(this.videoPlayer.currentTime).SegmentType;
|
||||
if (segmentType === "None" || this.currentOption === "Off" || !this.allowEnter) {
|
||||
if (this.skipButton.classList.contains('show')) {
|
||||
this.skipButton.classList.remove('show');
|
||||
embyButton.addEventListener("transitionend", () => {
|
||||
this.skipButton.classList.add("hide");
|
||||
if (this.osdVisible()) {
|
||||
this.osdElement.querySelector('button.btnPause').focus();
|
||||
} else {
|
||||
embyButton.originalBlur();
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.currentOption === "Automatically Skip" || (this.currentOption === "Button w/ auto PiP" && document.pictureInPictureElement)) {
|
||||
this.doSkip();
|
||||
return;
|
||||
}
|
||||
this.skipButton.querySelector("#btnSkipSegmentText").textContent = this.skipButton.dataset[segmentType];
|
||||
if (!this.skipButton.classList.contains("hide")) {
|
||||
if (!this.osdVisible() && !embyButton.contains(document.activeElement)) embyButton.focus();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
this.skipButton.classList.remove("hide");
|
||||
requestAnimationFrame(() => {
|
||||
this.skipButton.classList.add('show');
|
||||
this.overrideBlur(embyButton);
|
||||
embyButton.focus();
|
||||
});
|
||||
});
|
||||
},
|
||||
/** Seeks to the end of the intro. */
|
||||
doSkip() {
|
||||
if (!this.allowEnter) return;
|
||||
const segment = this.getCurrentSegment(this.videoPlayer.currentTime);
|
||||
if (segment.SegmentType === "None") {
|
||||
console.warn("[intro skipper] doSkip() called without an active segment");
|
||||
return;
|
||||
}
|
||||
this.d(`Skipping ${segment.SegmentType}`);
|
||||
this.allowEnter = false;
|
||||
const seekedHandler = () => {
|
||||
this.videoPlayer.removeEventListener('seeked', seekedHandler);
|
||||
setTimeout(() => {
|
||||
this.allowEnter = true;
|
||||
}, 500);
|
||||
};
|
||||
this.videoPlayer.addEventListener('seeked', seekedHandler);
|
||||
this.videoPlayer.currentTime = segment.SegmentType === "Credits" && this.videoPlayer.duration - segment.IntroEnd < 3
|
||||
? this.videoPlayer.duration + 10
|
||||
: segment.IntroEnd;
|
||||
},
|
||||
createButton(ref, id, innerHTML, clickHandler) {
|
||||
const button = ref.cloneNode(true);
|
||||
button.setAttribute('data-id', id);
|
||||
button.innerHTML = innerHTML;
|
||||
button.addEventListener('click', clickHandler);
|
||||
return button;
|
||||
},
|
||||
closeSubmenu(fullscreen) {
|
||||
document.querySelector('.dialogContainer').remove();
|
||||
document.querySelector('.dialogBackdrop').remove()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Control' }));
|
||||
if (!fullscreen) return;
|
||||
document.removeEventListener('keydown', this.handleEscapeKey);
|
||||
document.querySelector('.btnVideoOsdSettings').focus();
|
||||
},
|
||||
openSubmenu(ref, menu) {
|
||||
const options = ['Show Button', 'Button w/ auto PiP', 'Automatically Skip', 'Off'];
|
||||
const submenu = menu.cloneNode(true);
|
||||
const scroller = submenu.querySelector('.actionSheetScroller');
|
||||
scroller.innerHTML = '';
|
||||
options.forEach(option => {
|
||||
if (option !== 'Button w/ auto PiP' || document.pictureInPictureEnabled) {
|
||||
const button = this.createButton(ref, `introskipper-${option.toLowerCase().replace(' ', '-')}`,
|
||||
`<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons check" aria-hidden="true" style="visibility:${option === this.currentOption ? 'visible' : 'hidden'};"></span><div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">${option}</div></div>`,
|
||||
() => this.selectOption(option));
|
||||
scroller.appendChild(button);
|
||||
}
|
||||
});
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'dialogBackdrop dialogBackdropOpened';
|
||||
document.body.append(backdrop, submenu);
|
||||
const actionSheet = submenu.querySelector('.actionSheet');
|
||||
if (actionSheet.classList.contains('actionsheet-not-fullscreen')) {
|
||||
this.adjustPosition(actionSheet, document.querySelector('.btnVideoOsdSettings'));
|
||||
submenu.addEventListener('click', () => this.closeSubmenu(false));
|
||||
} else {
|
||||
submenu.querySelector('.btnCloseActionSheet').addEventListener('click', () => this.closeSubmenu(true))
|
||||
scroller.addEventListener('click', () => this.closeSubmenu(true))
|
||||
document.addEventListener('keydown', this.handleEscapeKey);
|
||||
setTimeout(() => scroller.firstElementChild.focus(), 240);
|
||||
}
|
||||
},
|
||||
selectOption(option) {
|
||||
this.currentOption = option;
|
||||
localStorage.setItem('introskipperOption', option);
|
||||
this.d(`Introskipper option selected and saved: ${option}`);
|
||||
},
|
||||
injectIntroSkipperOptions(actionSheet) {
|
||||
if (!this.skipButton) return;
|
||||
const statsButton = actionSheet.querySelector('[data-id="stats"]');
|
||||
if (!statsButton) return;
|
||||
const menuItem = this.createButton(statsButton, 'introskipperMenu',
|
||||
`<div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">Intro Skipper</div></div><div class="listItemAside actionSheetItemAsideText">${this.currentOption}</div>`,
|
||||
() => this.openSubmenu(statsButton, actionSheet.closest('.dialogContainer')));
|
||||
const originalWidth = actionSheet.offsetWidth;
|
||||
statsButton.before(menuItem);
|
||||
if (actionSheet.classList.contains('actionsheet-not-fullscreen')) this.adjustPosition(actionSheet, menuItem, originalWidth);
|
||||
},
|
||||
adjustPosition(element, reference, originalWidth) {
|
||||
if (originalWidth) {
|
||||
const currentTop = parseInt(element.style.top, 10) || 0;
|
||||
element.style.top = `${currentTop - reference.offsetHeight}px`;
|
||||
const newWidth = Math.max(reference.offsetWidth - originalWidth, 0);
|
||||
const originalLeft = parseInt(element.style.left, 10) || 0;
|
||||
element.style.left = `${originalLeft - newWidth / 2}px`;
|
||||
} else {
|
||||
const rect = reference.getBoundingClientRect();
|
||||
element.style.left = `${Math.min(rect.left - (element.offsetWidth - rect.width) / 2, window.innerWidth - element.offsetWidth - 10)}px`;
|
||||
element.style.top = `${rect.top - element.offsetHeight + rect.height}px`;
|
||||
}
|
||||
},
|
||||
injectSkipperFields(metadataFormFields) {
|
||||
const skipperFields = document.createElement('div');
|
||||
skipperFields.className = 'detailSection introskipperSection';
|
||||
skipperFields.innerHTML = `
|
||||
<h2>Intro Skipper</h2>
|
||||
<div class="inlineForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label>
|
||||
<input type="text" id="introStartDisplay" class="emby-input custom-time-input" readonly>
|
||||
<input type="number" id="introStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label>
|
||||
<input type="text" id="introEndDisplay" class="emby-input custom-time-input" readonly>
|
||||
<input type="number" id="introEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="inlineForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
|
||||
<input type="text" id="creditsStartDisplay" class="emby-input custom-time-input" readonly>
|
||||
<input type="number" id="creditsStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="creditsEnd">Credits End</label>
|
||||
<input type="text" id="creditsEndDisplay" class="emby-input custom-time-input" readonly>
|
||||
<input type="number" id="creditsEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
metadataFormFields.querySelector('#metadataSettingsCollapsible').insertAdjacentElement('afterend', skipperFields);
|
||||
this.attachSaveListener(metadataFormFields);
|
||||
this.updateSkipperFields(skipperFields);
|
||||
this.setTimeInputs(skipperFields);
|
||||
},
|
||||
updateSkipperFields(skipperFields) {
|
||||
const { Introduction = {}, Credits = {} } = this.skipperData;
|
||||
skipperFields.querySelector('#introStartEdit').value = Introduction.Start || 0;
|
||||
skipperFields.querySelector('#introEndEdit').value = Introduction.End || 0;
|
||||
skipperFields.querySelector('#creditsStartEdit').value = Credits.Start || 0;
|
||||
skipperFields.querySelector('#creditsEndEdit').value = Credits.End || 0;
|
||||
},
|
||||
attachSaveListener(metadataFormFields) {
|
||||
const saveButton = metadataFormFields.querySelector('.formDialogFooter .btnSave');
|
||||
if (saveButton) {
|
||||
saveButton.addEventListener('click', this.saveSkipperData.bind(this));
|
||||
} else {
|
||||
console.error('Save button not found');
|
||||
}
|
||||
},
|
||||
setTimeInputs(skipperFields) {
|
||||
const inputContainers = skipperFields.querySelectorAll('.inputContainer');
|
||||
inputContainers.forEach(container => {
|
||||
const displayInput = container.querySelector('[id$="Display"]');
|
||||
const editInput = container.querySelector('[id$="Edit"]');
|
||||
displayInput.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
this.switchToEdit(displayInput, editInput);
|
||||
});
|
||||
editInput.addEventListener('blur', () => this.switchToDisplay(displayInput, editInput));
|
||||
displayInput.value = this.formatTime(parseFloat(editInput.value) || 0);
|
||||
});
|
||||
},
|
||||
formatTime(totalSeconds) {
|
||||
const totalRoundedSeconds = Math.round(totalSeconds);
|
||||
const hours = Math.floor(totalRoundedSeconds / 3600);
|
||||
const minutes = Math.floor((totalRoundedSeconds % 3600) / 60);
|
||||
const seconds = totalRoundedSeconds % 60;
|
||||
let result = [];
|
||||
if (hours > 0) result.push(`${hours} hour${hours !== 1 ? 's' : ''}`);
|
||||
if (minutes > 0) result.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`);
|
||||
if (seconds > 0 || result.length === 0) result.push(`${seconds} second${seconds !== 1 ? 's' : ''}`);
|
||||
return result.join(' ');
|
||||
},
|
||||
switchToEdit(displayInput, editInput) {
|
||||
displayInput.style.display = 'none';
|
||||
editInput.style.display = '';
|
||||
editInput.focus();
|
||||
},
|
||||
switchToDisplay(displayInput, editInput) {
|
||||
editInput.style.display = 'none';
|
||||
displayInput.style.display = '';
|
||||
displayInput.value = this.formatTime(parseFloat(editInput.value) || 0);
|
||||
},
|
||||
async saveSkipperData() {
|
||||
const newTimestamps = {
|
||||
Introduction: {
|
||||
Start: parseFloat(document.getElementById('introStartEdit').value || 0),
|
||||
End: parseFloat(document.getElementById('introEndEdit').value || 0)
|
||||
},
|
||||
Credits: {
|
||||
Start: parseFloat(document.getElementById('creditsStartEdit').value || 0),
|
||||
End: parseFloat(document.getElementById('creditsEndEdit').value || 0)
|
||||
}
|
||||
};
|
||||
const { Introduction = {}, Credits = {} } = this.skipperData;
|
||||
if (newTimestamps.Introduction.Start !== (Introduction.Start || 0) ||
|
||||
newTimestamps.Introduction.End !== (Introduction.End || 0) ||
|
||||
newTimestamps.Credits.Start !== (Credits.Start || 0) ||
|
||||
newTimestamps.Credits.End !== (Credits.End || 0)) {
|
||||
const response = await this.secureFetch(`Episode/${this.currentEpisodeId}/Timestamps`, "POST", JSON.stringify(newTimestamps));
|
||||
this.d(response.ok ? 'Timestamps updated successfully' : 'Failed to update timestamps:', response.status);
|
||||
} else {
|
||||
this.d('Timestamps have not changed, skipping update');
|
||||
}
|
||||
},
|
||||
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
|
||||
async secureFetch(url, method = "GET", body = null) {
|
||||
const response = await fetch(`${ApiClient.serverAddress()}/${url}`, {
|
||||
method,
|
||||
headers: Object.assign({ "Authorization": `MediaBrowser Token=${ApiClient.accessToken()}` },
|
||||
method === "POST" ? {"Content-Type": "application/json"} : {}),
|
||||
body });
|
||||
return response.ok ? (method === "POST" ? response : response.json()) :
|
||||
response.status === 404 ? null :
|
||||
console.error(`Error ${response.status} from ${url}`) || null;
|
||||
},
|
||||
/** Handle keydown events. */
|
||||
eventHandler(e) {
|
||||
if (e.key !== "Enter") return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.doSkip();
|
||||
},
|
||||
handleEscapeKey(e) {
|
||||
if (e.key === 'Escape' || e.keyCode === 461 || e.keyCode === 10009) {
|
||||
e.stopPropagation();
|
||||
this.closeSubmenu(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
introSkipper.setup();
|
@ -1,226 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Mime;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Skip intro controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class SkipIntroController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SkipIntroController"/> class.
|
||||
/// </summary>
|
||||
public SkipIntroController()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format.
|
||||
/// </summary>
|
||||
/// <param name="id">ID of the episode. Required.</param>
|
||||
/// <param name="mode">Timestamps to return. Optional. Defaults to Introduction for backwards compatibility.</param>
|
||||
/// <response code="200">Episode contains an intro.</response>
|
||||
/// <response code="404">Failed to find an intro in the provided episode.</response>
|
||||
/// <returns>Detected intro.</returns>
|
||||
[HttpGet("Episode/{id}/IntroTimestamps")]
|
||||
[HttpGet("Episode/{id}/IntroTimestamps/v1")]
|
||||
public ActionResult<Intro> GetIntroTimestamps(
|
||||
[FromRoute] Guid id,
|
||||
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
|
||||
{
|
||||
var intro = GetIntro(id, mode);
|
||||
|
||||
if (intro is null || !intro.Valid)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return intro;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the timestamps for the provided episode.
|
||||
/// </summary>
|
||||
/// <param name="id">Episode ID to update timestamps for.</param>
|
||||
/// <param name="timestamps">New timestamps Introduction/Credits start and end times.</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)
|
||||
{
|
||||
// only update existing episodes
|
||||
var rawItem = Plugin.Instance!.GetItem(id);
|
||||
if (rawItem == null || rawItem is not Episode episode)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (timestamps?.Introduction.End > 0.0)
|
||||
{
|
||||
var tr = new TimeRange(timestamps.Introduction.Start, timestamps.Introduction.End);
|
||||
Plugin.Instance!.Intros[id] = new Segment(id, tr);
|
||||
}
|
||||
|
||||
if (timestamps?.Credits.End > 0.0)
|
||||
{
|
||||
var cr = new TimeRange(timestamps.Credits.Start, timestamps.Credits.End);
|
||||
Plugin.Instance!.Credits[id] = new Segment(id, cr);
|
||||
}
|
||||
|
||||
Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction);
|
||||
Plugin.Instance!.SaveTimestamps(AnalysisMode.Credits);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamps for the provided episode.
|
||||
/// </summary>
|
||||
/// <param name="id">Episode ID.</param>
|
||||
/// <response code="200">Sucess.</response>
|
||||
/// <response code="404">Given ID is not an Episode.</response>
|
||||
/// <returns>Episode Timestamps.</returns>
|
||||
[HttpGet("Episode/{Id}/Timestamps")]
|
||||
[ActionName("UpdateTimestamps")]
|
||||
public ActionResult<TimeStamps> GetTimestamps([FromRoute] Guid id)
|
||||
{
|
||||
// only get return content for episodes
|
||||
var rawItem = Plugin.Instance!.GetItem(id);
|
||||
if (rawItem == null || rawItem is not Episode episode)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var times = new TimeStamps();
|
||||
if (Plugin.Instance!.Intros.TryGetValue(id, out var introValue))
|
||||
{
|
||||
times.Introduction = introValue;
|
||||
}
|
||||
|
||||
if (Plugin.Instance!.Credits.TryGetValue(id, out var creditValue))
|
||||
{
|
||||
times.Credits = creditValue;
|
||||
}
|
||||
|
||||
return times;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a dictionary of all skippable segments.
|
||||
/// </summary>
|
||||
/// <param name="id">Media ID.</param>
|
||||
/// <response code="200">Skippable segments dictionary.</response>
|
||||
/// <returns>Dictionary of skippable segments.</returns>
|
||||
[HttpGet("Episode/{id}/IntroSkipperSegments")]
|
||||
public ActionResult<Dictionary<AnalysisMode, Intro>> GetSkippableSegments([FromRoute] Guid id)
|
||||
{
|
||||
var segments = new Dictionary<AnalysisMode, Intro>();
|
||||
|
||||
if (GetIntro(id, AnalysisMode.Introduction) is Intro intro)
|
||||
{
|
||||
segments[AnalysisMode.Introduction] = intro;
|
||||
}
|
||||
|
||||
if (GetIntro(id, AnalysisMode.Credits) is Intro credits)
|
||||
{
|
||||
segments[AnalysisMode.Credits] = credits;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
try
|
||||
{
|
||||
var timestamp = Plugin.GetIntroByMode(id, mode);
|
||||
|
||||
// Operate on a copy to avoid mutating the original Intro object stored in the dictionary.
|
||||
var segment = new Intro(timestamp);
|
||||
|
||||
var config = Plugin.Instance!.Configuration;
|
||||
segment.IntroEnd -= config.RemainingSecondsOfIntro;
|
||||
if (config.PersistSkipButton)
|
||||
{
|
||||
segment.ShowSkipPromptAt = segment.IntroStart;
|
||||
segment.HideSkipPromptAt = segment.IntroEnd;
|
||||
}
|
||||
else
|
||||
{
|
||||
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
|
||||
segment.HideSkipPromptAt = Math.Min(
|
||||
segment.IntroStart + config.HidePromptAdjustment,
|
||||
segment.IntroEnd);
|
||||
}
|
||||
|
||||
return segment;
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erases all previously discovered introduction timestamps.
|
||||
/// </summary>
|
||||
/// <param name="mode">Mode.</param>
|
||||
/// <param name="eraseCache">Erase cache.</param>
|
||||
/// <response code="204">Operation successful.</response>
|
||||
/// <returns>No content.</returns>
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[HttpPost("Intros/EraseTimestamps")]
|
||||
public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false)
|
||||
{
|
||||
if (mode == AnalysisMode.Introduction)
|
||||
{
|
||||
Plugin.Instance!.Intros.Clear();
|
||||
}
|
||||
else if (mode == AnalysisMode.Credits)
|
||||
{
|
||||
Plugin.Instance!.Credits.Clear();
|
||||
}
|
||||
|
||||
if (eraseCache)
|
||||
{
|
||||
FFmpegWrapper.DeleteCacheFiles(mode);
|
||||
}
|
||||
|
||||
Plugin.Instance!.EpisodeStates.Clear();
|
||||
Plugin.Instance!.SaveTimestamps(mode);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user interface configuration.
|
||||
/// </summary>
|
||||
/// <response code="200">UserInterfaceConfiguration returned.</response>
|
||||
/// <returns>UserInterfaceConfiguration.</returns>
|
||||
[HttpGet]
|
||||
[Route("Intros/UserInterfaceConfiguration")]
|
||||
public ActionResult<UserInterfaceConfiguration> GetUserInterfaceConfiguration()
|
||||
{
|
||||
var config = Plugin.Instance!.Configuration;
|
||||
return new UserInterfaceConfiguration(
|
||||
config.SkipButtonVisible,
|
||||
config.SkipButtonIntroText,
|
||||
config.SkipButtonEndCreditsText);
|
||||
}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Type of media file analysis to perform.
|
||||
/// </summary>
|
||||
public enum AnalysisMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Detect introduction sequences.
|
||||
/// </summary>
|
||||
Introduction,
|
||||
|
||||
/// <summary>
|
||||
/// Detect credits.
|
||||
/// </summary>
|
||||
Credits,
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
namespace ConfusedPolarBear.Plugin.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 intro from playback as if it was never in the original video.
|
||||
/// </summary>
|
||||
Cut,
|
||||
|
||||
/// <summary>
|
||||
/// Mute audio, continue playback.
|
||||
/// </summary>
|
||||
Mute,
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a new scene marker.
|
||||
/// </summary>
|
||||
SceneMarker,
|
||||
|
||||
/// <summary>
|
||||
/// Automatically skip the intro once during playback.
|
||||
/// </summary>
|
||||
CommercialBreak,
|
||||
|
||||
/// <summary>
|
||||
/// Show a skip button.
|
||||
/// </summary>
|
||||
Intro,
|
||||
|
||||
/// <summary>
|
||||
/// Show a skip button.
|
||||
/// </summary>
|
||||
Credit,
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.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,89 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.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,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,123 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.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;
|
||||
}
|
||||
|
||||
if (action == EdlAction.Intro)
|
||||
{
|
||||
edlContent += credit?.ToEdl(EdlAction.Credit);
|
||||
}
|
||||
else
|
||||
{
|
||||
edlContent += credit?.ToEdl(action);
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(edlPath, edlContent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Given the path to an episode, return the path to the associated EDL file.
|
||||
/// </summary>
|
||||
/// <param name="mediaPath">Full path to episode.</param>
|
||||
/// <returns>Full path to EDL file.</returns>
|
||||
public static string GetEdlPath(string mediaPath)
|
||||
{
|
||||
return Path.ChangeExtension(mediaPath, "edl");
|
||||
}
|
||||
}
|
@ -1,296 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
|
||||
/// <summary>
|
||||
/// Manages enqueuing library items for analysis.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="QueueManager"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryManager)
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager = libraryManager;
|
||||
private readonly ILogger<QueueManager> _logger = logger;
|
||||
private readonly Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes = [];
|
||||
private double _analysisPercent;
|
||||
private List<string> _selectedLibraries = [];
|
||||
private bool _selectAllLibraries;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all media items on the server.
|
||||
/// </summary>
|
||||
/// <returns>Queued media items.</returns>
|
||||
public IReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems()
|
||||
{
|
||||
Plugin.Instance!.TotalQueued = 0;
|
||||
|
||||
LoadAnalysisSettings();
|
||||
|
||||
// For all selected libraries, enqueue all contained episodes.
|
||||
foreach (var folder in _libraryManager.GetVirtualFolders())
|
||||
{
|
||||
// If libraries have been selected for analysis, ensure this library was selected.
|
||||
if (!_selectAllLibraries && !_selectedLibraries.Contains(folder.Name))
|
||||
{
|
||||
_logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Running enqueue of items in library {Name}", folder.Name);
|
||||
|
||||
// Some virtual folders don't have a proper item id.
|
||||
if (!Guid.TryParse(folder.ItemId, out var folderId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
QueueLibraryContents(folderId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex);
|
||||
}
|
||||
}
|
||||
|
||||
Plugin.Instance.TotalSeasons = _queuedEpisodes.Count;
|
||||
Plugin.Instance.QueuedMediaItems.Clear();
|
||||
foreach (var kvp in _queuedEpisodes)
|
||||
{
|
||||
Plugin.Instance.QueuedMediaItems.TryAdd(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return _queuedEpisodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the list of libraries which have been selected for analysis and the minimum intro duration.
|
||||
/// Settings which have been modified from the defaults are logged.
|
||||
/// </summary>
|
||||
private void LoadAnalysisSettings()
|
||||
{
|
||||
var config = Plugin.Instance!.Configuration;
|
||||
|
||||
// Store the analysis percent
|
||||
_analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100;
|
||||
|
||||
_selectAllLibraries = config.SelectAllLibraries;
|
||||
|
||||
if (!_selectAllLibraries)
|
||||
{
|
||||
// Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries.
|
||||
_selectedLibraries = [.. config.SelectedLibraries.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||
|
||||
// If any libraries have been selected for analysis, log their names.
|
||||
_logger.LogInformation("Limiting analysis to the following libraries: {Selected}", _selectedLibraries);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Not limiting analysis by library name");
|
||||
}
|
||||
|
||||
// If analysis settings have been changed from the default, log the modified settings.
|
||||
if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s",
|
||||
config.AnalysisPercent,
|
||||
config.AnalysisLengthLimit,
|
||||
config.MinimumIntroDuration);
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueLibraryContents(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Constructing anonymous internal query");
|
||||
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
// Order by series name, season, and then episode number so that status updates are logged in order
|
||||
ParentId = id,
|
||||
OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),],
|
||||
IncludeItemTypes = [BaseItemKind.Episode],
|
||||
Recursive = true,
|
||||
IsVirtualItem = false
|
||||
};
|
||||
|
||||
var items = _libraryManager.GetItemList(query, false);
|
||||
|
||||
if (items is null)
|
||||
{
|
||||
_logger.LogError("Library query result is null");
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue all episodes on the server for fingerprinting.
|
||||
_logger.LogDebug("Iterating through library items");
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is not Episode episode)
|
||||
{
|
||||
_logger.LogDebug("Item {Name} is not an episode", item.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
QueueEpisode(episode);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Queued {Count} episodes", items.Count);
|
||||
}
|
||||
|
||||
private void QueueEpisode(Episode episode)
|
||||
{
|
||||
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");
|
||||
|
||||
if (string.IsNullOrEmpty(episode.Path))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin",
|
||||
episode.Name,
|
||||
episode.SeriesName,
|
||||
episode.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allocate a new list for each new season
|
||||
var seasonId = GetSeasonId(episode);
|
||||
if (!_queuedEpisodes.TryGetValue(seasonId, out var seasonEpisodes))
|
||||
{
|
||||
seasonEpisodes = [];
|
||||
_queuedEpisodes[seasonId] = seasonEpisodes;
|
||||
}
|
||||
|
||||
if (seasonEpisodes.Any(e => e.EpisodeId == episode.Id))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"\"{Name}\" from series \"{Series}\" ({Id}) is already queued",
|
||||
episode.Name,
|
||||
episode.SeriesName,
|
||||
episode.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var isAnime = seasonEpisodes.FirstOrDefault()?.IsAnime ??
|
||||
(pluginInstance.GetItem(episode.SeriesId) is Series series &&
|
||||
(series.Tags.Contains("anime", StringComparison.OrdinalIgnoreCase) ||
|
||||
series.Genres.Contains("anime", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
// Limit analysis to the first X% of the episode and at most Y minutes.
|
||||
// X and Y default to 25% and 10 minutes.
|
||||
var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
|
||||
var fingerprintDuration = Math.Min(
|
||||
duration >= 5 * 60 ? duration * _analysisPercent : duration,
|
||||
60 * pluginInstance.Configuration.AnalysisLengthLimit);
|
||||
|
||||
// Queue the episode for analysis
|
||||
var maxCreditsDuration = pluginInstance.Configuration.MaximumCreditsDuration;
|
||||
seasonEpisodes.Add(new QueuedEpisode
|
||||
{
|
||||
SeriesName = episode.SeriesName,
|
||||
SeasonNumber = episode.AiredSeasonNumber ?? 0,
|
||||
SeriesId = episode.SeriesId,
|
||||
EpisodeId = episode.Id,
|
||||
Name = episode.Name,
|
||||
IsAnime = isAnime,
|
||||
Path = episode.Path,
|
||||
Duration = Convert.ToInt32(duration),
|
||||
IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration),
|
||||
CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration),
|
||||
});
|
||||
|
||||
pluginInstance.TotalQueued++;
|
||||
}
|
||||
|
||||
private Guid GetSeasonId(Episode episode)
|
||||
{
|
||||
if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special
|
||||
{
|
||||
foreach (var kvp in _queuedEpisodes)
|
||||
{
|
||||
var first = kvp.Value.FirstOrDefault();
|
||||
if (first?.SeriesId == episode.SeriesId &&
|
||||
first.SeasonNumber == episode.AiredSeasonNumber)
|
||||
{
|
||||
return kvp.Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return episode.SeasonId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a collection of queued media items still exist in Jellyfin and in storage.
|
||||
/// This is done to ensure that we don't analyze items that were deleted between the call to GetMediaItems() and popping them from the queue.
|
||||
/// </summary>
|
||||
/// <param name="candidates">Queued media items.</param>
|
||||
/// <param name="modes">Analysis mode.</param>
|
||||
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
|
||||
public (IReadOnlyList<QueuedEpisode> VerifiedItems, IReadOnlyCollection<AnalysisMode> RequiredModes)
|
||||
VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<AnalysisMode> modes)
|
||||
{
|
||||
var verified = new List<QueuedEpisode>();
|
||||
var reqModes = new HashSet<AnalysisMode>();
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
verified.Add(candidate);
|
||||
|
||||
foreach (var mode in modes)
|
||||
{
|
||||
if (candidate.State.IsAnalyzed(mode) || candidate.State.IsBlacklisted(mode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool isAnalyzed = mode == AnalysisMode.Introduction
|
||||
? Plugin.Instance!.Intros.ContainsKey(candidate.EpisodeId)
|
||||
: Plugin.Instance!.Credits.ContainsKey(candidate.EpisodeId);
|
||||
|
||||
if (isAnalyzed)
|
||||
{
|
||||
candidate.State.SetAnalyzed(mode, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
reqModes.Add(mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping analysis of {Name} ({Id}): {Exception}",
|
||||
candidate.Name,
|
||||
candidate.EpisodeId,
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
return (verified, reqModes);
|
||||
}
|
||||
}
|
@ -1,503 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
|
||||
/// <summary>
|
||||
/// Intro skipper plugin. Uses audio analysis to find common sequences of audio shared between episodes.
|
||||
/// </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;
|
||||
|
||||
/// <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>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
/// <param name="itemRepository">Item repository.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public Plugin(
|
||||
IApplicationHost applicationHost,
|
||||
IApplicationPaths applicationPaths,
|
||||
IXmlSerializer xmlSerializer,
|
||||
IServerConfigurationManager serverConfiguration,
|
||||
ILibraryManager libraryManager,
|
||||
IItemRepository itemRepository,
|
||||
ILogger<Plugin> logger)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
|
||||
_applicationHost = applicationHost;
|
||||
_libraryManager = libraryManager;
|
||||
_itemRepository = itemRepository;
|
||||
_logger = logger;
|
||||
|
||||
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
|
||||
|
||||
ArgumentNullException.ThrowIfNull(applicationPaths);
|
||||
|
||||
var pluginDirName = "introskipper";
|
||||
var pluginCachePath = "chromaprints";
|
||||
|
||||
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");
|
||||
|
||||
// Create the base & cache directories (if needed).
|
||||
if (!Directory.Exists(FingerprintCachePath))
|
||||
{
|
||||
Directory.CreateDirectory(FingerprintCachePath);
|
||||
}
|
||||
|
||||
// migrate from XMLSchema to DataContract
|
||||
XmlSerializationHelper.MigrateXML(_introPath);
|
||||
XmlSerializationHelper.MigrateXML(_creditsPath);
|
||||
|
||||
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
|
||||
try
|
||||
{
|
||||
RestoreTimestamps();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LoadIgnoreList();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
FFmpegWrapper.CheckFFmpegVersion();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the results of fingerprinting all episodes.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<Guid, Segment> Intros { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all discovered ending credits.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<Guid, Segment> Credits { get; } = new();
|
||||
|
||||
/// <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>
|
||||
public int TotalQueued { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of seasons in the queue.
|
||||
/// </summary>
|
||||
public int TotalSeasons { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory to cache fingerprints in.
|
||||
/// </summary>
|
||||
public string FingerprintCachePath { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full path to FFmpeg.
|
||||
/// </summary>
|
||||
public string FFmpegPath { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Intro Skipper";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin instance.
|
||||
/// </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()
|
||||
{
|
||||
return
|
||||
[
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = "visualizer.js",
|
||||
EmbeddedResourcePath = GetType().Namespace + ".Configuration.visualizer.js"
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = "skip-intro-button.js",
|
||||
EmbeddedResourcePath = GetType().Namespace + ".Configuration.inject.js"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var item = GetItem(id);
|
||||
return item is not null ? _libraryManager.GetCollectionFolders(item) : [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full path for an item.
|
||||
/// </summary>
|
||||
/// <param name="id">Item id.</param>
|
||||
/// <returns>Full path to item.</returns>
|
||||
internal string GetItemPath(Guid id)
|
||||
{
|
||||
var item = GetItem(id);
|
||||
if (item == null)
|
||||
{
|
||||
// Handle the case where the item is not found
|
||||
_logger.LogWarning("Item with ID {Id} not found.", id);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return item.Path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all chapters for this item.
|
||||
/// </summary>
|
||||
/// <param name="id">Item id.</param>
|
||||
/// <returns>List of chapters.</returns>
|
||||
internal List<ChapterInfo> GetChapters(Guid id)
|
||||
{
|
||||
var item = GetItem(id);
|
||||
if (item == null)
|
||||
{
|
||||
// Handle the case where the item is not found
|
||||
_logger.LogWarning("Item with ID {Id} not found.", id);
|
||||
return [];
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
/// <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 buildVersionString = "unknow";
|
||||
string webVersionString = "unknow";
|
||||
// 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)
|
||||
{
|
||||
buildVersionString = match.Groups["buildVersion"].Value;
|
||||
webVersionString = match.Groups["webVersion"].Value;
|
||||
_logger.LogInformation("Found jellyfin-web <{WebVersion}>", webVersionString);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (webVersionString != "unknow")
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("The jellyfin-web <{WebVersion}> compatible with Jellyfin <{JellyfinVersion}>", webVersionString, _applicationHost.ApplicationVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
// search for class btnSkipIntro
|
||||
if (File.ReadAllText(file).Contains("btnSkipIntro", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Found a modified version of jellyfin-web with built-in skip button support.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject the skip intro button code into the web interface.
|
||||
string indexPath = Path.Join(webPath, "index.html");
|
||||
|
||||
// Parts of this code are based off of JellyScrub's script injection code.
|
||||
// https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/JellyscrubPlugin.cs#L38
|
||||
|
||||
_logger.LogDebug("Reading index.html from {Path}", indexPath);
|
||||
string contents = File.ReadAllText(indexPath);
|
||||
|
||||
// change URL with every relase to prevent the Browers from caching
|
||||
string scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js&release=" + GetType().Assembly.GetName().Version + "\"></script>";
|
||||
|
||||
// Only inject the script tag once
|
||||
if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("The skip button has already been injected.");
|
||||
return;
|
||||
}
|
||||
|
||||
// remove old version if necessary
|
||||
pattern = @"<script src=""configurationpage\?name=skip-intro-button\.js.*<\/script>";
|
||||
contents = Regex.Replace(contents, pattern, string.Empty, RegexOptions.IgnoreCase);
|
||||
|
||||
// Inject a link to the script at the end of the <head> section.
|
||||
// A regex is used here to ensure the replacement is only done once.
|
||||
Regex headEnd = new Regex(@"</head>", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
|
||||
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
|
||||
|
||||
// Write the modified file contents
|
||||
File.WriteAllText(indexPath, contents);
|
||||
|
||||
_logger.LogInformation("Skip button added successfully.");
|
||||
}
|
||||
}
|
@ -1,245 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.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,
|
||||
ILogger logger,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_analysisModes = modes;
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
_libraryManager = libraryManager;
|
||||
|
||||
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
|
||||
{
|
||||
EdlManager.Initialize(_logger);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyze all media items on the server.
|
||||
/// </summary>
|
||||
/// <param name="progress">Progress.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <param name="seasonsToAnalyze">Season Ids to analyze.</param>
|
||||
public void AnalyzeItems(
|
||||
IProgress<double> progress,
|
||||
CancellationToken cancellationToken,
|
||||
IReadOnlyCollection<Guid>? seasonsToAnalyze = null)
|
||||
{
|
||||
var ffmpegValid = FFmpegWrapper.CheckFFmpegVersion();
|
||||
// Assert that ffmpeg with chromaprint is installed
|
||||
if (Plugin.Instance!.Configuration.UseChromaprint && !ffmpegValid)
|
||||
{
|
||||
throw new FingerprintException(
|
||||
"Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg5. If Jellyfin is running in a container, upgrade to version 10.8.0 or newer.");
|
||||
}
|
||||
|
||||
var queueManager = new QueueManager(
|
||||
_loggerFactory.CreateLogger<QueueManager>(),
|
||||
_libraryManager);
|
||||
|
||||
var queue = queueManager.GetMediaItems();
|
||||
|
||||
// Filter the queue based on seasonsToAnalyze
|
||||
if (seasonsToAnalyze is { Count: > 0 })
|
||||
{
|
||||
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;
|
||||
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;
|
||||
var options = new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Plugin.Instance.Configuration.MaxParallelism
|
||||
};
|
||||
|
||||
Parallel.ForEach(queue, options, season =>
|
||||
{
|
||||
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());
|
||||
|
||||
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);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (AnalysisMode mode in requiredModes)
|
||||
{
|
||||
var analyzed = AnalyzeItems(episodes, mode, cancellationToken);
|
||||
Interlocked.Add(ref totalProcessed, analyzed);
|
||||
|
||||
writeEdl = analyzed > 0 || Plugin.Instance.Configuration.RegenerateEdlFiles;
|
||||
|
||||
progress.Report(totalProcessed * 100 / totalQueued);
|
||||
}
|
||||
}
|
||||
catch (FingerprintException ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to analyze {Series} season {Season}: unable to fingerprint: {Ex}",
|
||||
first.SeriesName,
|
||||
first.SeasonNumber,
|
||||
ex);
|
||||
}
|
||||
|
||||
if (writeEdl && Plugin.Instance.Configuration.EdlAction != EdlAction.None)
|
||||
{
|
||||
EdlManager.UpdateEDLFiles(episodes);
|
||||
}
|
||||
});
|
||||
|
||||
if (Plugin.Instance.Configuration.RegenerateEdlFiles)
|
||||
{
|
||||
_logger.LogInformation("Turning EDL file regeneration flag off");
|
||||
Plugin.Instance.Configuration.RegenerateEdlFiles = false;
|
||||
Plugin.Instance.SaveConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyze a group of media items for skippable segments.
|
||||
/// </summary>
|
||||
/// <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(
|
||||
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.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Remove from Blacklist
|
||||
foreach (var item in items.Where(e => e.State.IsBlacklisted(mode)))
|
||||
{
|
||||
item.State.SetBlacklisted(mode, false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}",
|
||||
mode,
|
||||
items.Count,
|
||||
first.SeriesName,
|
||||
first.SeasonNumber);
|
||||
|
||||
var analyzers = new Collection<IMediaFileAnalyzer>
|
||||
{
|
||||
new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>())
|
||||
};
|
||||
|
||||
if (first.IsAnime && Plugin.Instance!.Configuration.UseChromaprint)
|
||||
{
|
||||
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
|
||||
}
|
||||
|
||||
if (mode == AnalysisMode.Credits)
|
||||
{
|
||||
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
|
||||
}
|
||||
|
||||
if (!first.IsAnime && Plugin.Instance!.Configuration.UseChromaprint)
|
||||
{
|
||||
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
|
||||
}
|
||||
|
||||
// Use each analyzer to find skippable ranges in all media files, removing successfully
|
||||
// analyzed items from the queue.
|
||||
foreach (var analyzer in analyzers)
|
||||
{
|
||||
items = analyzer.AnalyzeMediaFiles(items, mode, cancellationToken);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
return totalItems;
|
||||
}
|
||||
}
|
@ -1,107 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.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,113 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Analyze all television episodes for introduction sequences.
|
||||
/// </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,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task name.
|
||||
/// </summary>
|
||||
public string Name => "Detect Intros and 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 intros and credits.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task key.
|
||||
/// </summary>
|
||||
public string Key => "CPBIntroSkipperDetectIntrosCredits";
|
||||
|
||||
/// <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, AnalysisMode.Credits };
|
||||
|
||||
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
|
||||
modes,
|
||||
_loggerFactory.CreateLogger<DetectIntrosCreditsTask>(),
|
||||
_loggerFactory,
|
||||
_libraryManager);
|
||||
|
||||
baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get task triggers.
|
||||
/// </summary>
|
||||
/// <returns>Task triggers.</returns>
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
return
|
||||
[
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerDaily,
|
||||
TimeOfDayTicks = TimeSpan.FromHours(0).Ticks
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
@ -1,106 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.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 [];
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
|
||||
|
||||
internal sealed class ScheduledTaskSemaphore : IDisposable
|
||||
{
|
||||
private static readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
private ScheduledTaskSemaphore()
|
||||
{
|
||||
}
|
||||
|
||||
public static IDisposable Acquire(CancellationToken cancellationToken)
|
||||
{
|
||||
_semaphore.Wait(cancellationToken);
|
||||
return new ScheduledTaskSemaphore();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
@ -1,230 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using ConfusedPolarBear.Plugin.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 ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
|
||||
/// <summary>
|
||||
/// Automatically skip past introduction sequences.
|
||||
/// Commands clients to seek to the end of the intro as soon as they start playing it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="userDataManager">User data manager.</param>
|
||||
/// <param name="sessionManager">Session manager.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public class AutoSkip(
|
||||
IUserDataManager userDataManager,
|
||||
ISessionManager sessionManager,
|
||||
ILogger<AutoSkip> logger) : IHostedService, IDisposable
|
||||
{
|
||||
private readonly object _sentSeekCommandLock = new();
|
||||
|
||||
private ILogger<AutoSkip> _logger = logger;
|
||||
private IUserDataManager _userDataManager = userDataManager;
|
||||
private ISessionManager _sessionManager = sessionManager;
|
||||
private Timer _playbackTimer = new(1000);
|
||||
private Dictionary<string, bool> _sentSeekCommand = [];
|
||||
private HashSet<string> _clientList = [];
|
||||
|
||||
private void AutoSkipChanged(object? sender, BasePluginConfiguration e)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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.AutoSkip || (Plugin.Instance!.Configuration.SkipButtonVisible && _clientList.Contains(s.Client, StringComparer.OrdinalIgnoreCase))))
|
||||
{
|
||||
var deviceId = session.DeviceId;
|
||||
var itemId = session.NowPlayingItem.Id;
|
||||
var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
|
||||
|
||||
// Don't send the seek command more than once in the same session.
|
||||
lock (_sentSeekCommandLock)
|
||||
{
|
||||
if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent)
|
||||
{
|
||||
_logger.LogTrace("Already sent seek command for session {Session}", deviceId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert that an intro was detected for this item.
|
||||
if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Seek is unreliable if called at the very start of an episode.
|
||||
var adjustedStart = Math.Max(1, intro.Start + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay);
|
||||
var adjustedEnd = intro.End - Plugin.Instance.Configuration.RemainingSecondsOfIntro;
|
||||
|
||||
_logger.LogTrace(
|
||||
"Playback position is {Position}, intro runs from {Start} to {End}",
|
||||
position,
|
||||
adjustedStart,
|
||||
adjustedEnd);
|
||||
|
||||
if (position < adjustedStart || position > adjustedEnd)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Notify the user that an introduction is being skipped for them.
|
||||
var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText;
|
||||
if (!string.IsNullOrWhiteSpace(notificationText))
|
||||
{
|
||||
_sessionManager.SendMessageCommand(
|
||||
session.Id,
|
||||
session.Id,
|
||||
new MessageCommand
|
||||
{
|
||||
Header = string.Empty, // some clients require header to be a string instead of null
|
||||
Text = notificationText,
|
||||
TimeoutMs = 2000,
|
||||
},
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Sending seek command to {Session}", deviceId);
|
||||
|
||||
_sessionManager.SendPlaystateCommand(
|
||||
session.Id,
|
||||
session.Id,
|
||||
new PlaystateRequest
|
||||
{
|
||||
Command = PlaystateCommand.Seek,
|
||||
ControllingUserId = session.UserId.ToString(),
|
||||
SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond,
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
// Flag that we've sent the seek command so that it's not sent repeatedly
|
||||
lock (_sentSeekCommandLock)
|
||||
{
|
||||
_logger.LogTrace("Setting seek command state for session {Session}", deviceId);
|
||||
_sentSeekCommand[deviceId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protected dispose.
|
||||
/// </summary>
|
||||
/// <param name="disposing">Dispose.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!disposing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_playbackTimer.Stop();
|
||||
_playbackTimer.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Setting up automatic skipping");
|
||||
|
||||
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
|
||||
Plugin.Instance!.ConfigurationChanged += AutoSkipChanged;
|
||||
|
||||
// Make the timer restart automatically and set enabled to match the configuration value.
|
||||
_playbackTimer.AutoReset = true;
|
||||
_playbackTimer.Elapsed += PlaybackTimer_Elapsed;
|
||||
|
||||
AutoSkipChanged(null, Plugin.Instance.Configuration);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
@ -1,230 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using ConfusedPolarBear.Plugin.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 ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
|
||||
/// <summary>
|
||||
/// Automatically skip past credit sequences.
|
||||
/// Commands clients to seek to the end of the credits as soon as they start playing it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="AutoSkipCredits"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="userDataManager">User data manager.</param>
|
||||
/// <param name="sessionManager">Session manager.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public class AutoSkipCredits(
|
||||
IUserDataManager userDataManager,
|
||||
ISessionManager sessionManager,
|
||||
ILogger<AutoSkipCredits> logger) : IHostedService, IDisposable
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,322 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
|
||||
/// <summary>
|
||||
/// Server entrypoint.
|
||||
/// </summary>
|
||||
public sealed class Entrypoint : IHostedService, IDisposable
|
||||
{
|
||||
private readonly ITaskManager _taskManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<Entrypoint> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly HashSet<Guid> _seasonsToAnalyze = [];
|
||||
private readonly Timer _queueTimer;
|
||||
private readonly PluginConfiguration _config;
|
||||
private static readonly ManualResetEventSlim _autoTaskCompletEvent = new(false);
|
||||
private bool _analyzeAgain;
|
||||
private static CancellationTokenSource? _cancellationTokenSource;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Entrypoint"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
/// <param name="taskManager">Task manager.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="loggerFactory">Logger factory.</param>
|
||||
public Entrypoint(
|
||||
ILibraryManager libraryManager,
|
||||
ITaskManager taskManager,
|
||||
ILogger<Entrypoint> logger,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_taskManager = taskManager;
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
|
||||
_config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
_queueTimer = new Timer(
|
||||
OnTimerCallback,
|
||||
null,
|
||||
Timeout.InfiniteTimeSpan,
|
||||
Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets State of the automatic task.
|
||||
/// </summary>
|
||||
public static TaskState AutomaticTaskState
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cancellationTokenSource is not null)
|
||||
{
|
||||
return _cancellationTokenSource.IsCancellationRequested
|
||||
? TaskState.Cancelling
|
||||
: TaskState.Running;
|
||||
}
|
||||
|
||||
return TaskState.Idle;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_libraryManager.ItemAdded += OnItemAdded;
|
||||
_libraryManager.ItemUpdated += OnItemModified;
|
||||
_taskManager.TaskCompleted += OnLibraryRefresh;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_libraryManager.ItemAdded -= OnItemAdded;
|
||||
_libraryManager.ItemUpdated -= OnItemModified;
|
||||
_taskManager.TaskCompleted -= OnLibraryRefresh;
|
||||
|
||||
// Stop the timer
|
||||
_queueTimer.Change(Timeout.Infinite, 0);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Disclose source for inspiration
|
||||
// Implementation based on the principles of jellyfin-plugin-media-analyzer:
|
||||
// https://github.com/endrl/jellyfin-plugin-media-analyzer
|
||||
|
||||
/// <summary>
|
||||
/// Library item was added.
|
||||
/// </summary>
|
||||
/// <param name="sender">The sending entity.</param>
|
||||
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
|
||||
private void OnItemAdded(object? sender, ItemChangeEventArgs itemChangeEventArgs)
|
||||
{
|
||||
// Don't do anything if auto detection is disabled
|
||||
if (!_config.AutoDetectIntros && !_config.AutoDetectCredits)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 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>
|
||||
/// Library item was modified.
|
||||
/// </summary>
|
||||
/// <param name="sender">The sending entity.</param>
|
||||
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
|
||||
private void OnItemModified(object? sender, ItemChangeEventArgs itemChangeEventArgs)
|
||||
{
|
||||
// Don't do anything if auto detection is disabled
|
||||
if (!_config.AutoDetectIntros && !_config.AutoDetectCredits)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't do anything if it's not a supported media type
|
||||
if (itemChangeEventArgs.Item is not Episode episode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_seasonsToAnalyze.Add(episode.SeasonId);
|
||||
|
||||
StartTimer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TaskManager task ended.
|
||||
/// </summary>
|
||||
/// <param name="sender">The sending entity.</param>
|
||||
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
|
||||
private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs)
|
||||
{
|
||||
// Don't do anything if auto detection is disabled
|
||||
if (!_config.AutoDetectIntros && !_config.AutoDetectCredits)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var result = eventArgs.Result;
|
||||
|
||||
if (result.Key != "RefreshLibrary")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Status != TaskCompletionStatus.Completed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Unless user initiated, this is likely an overlap
|
||||
if (AutomaticTaskState == TaskState.Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StartTimer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start timer to debounce analyzing.
|
||||
/// </summary>
|
||||
private void StartTimer()
|
||||
{
|
||||
if (AutomaticTaskState == TaskState.Running)
|
||||
{
|
||||
_analyzeAgain = true;
|
||||
}
|
||||
else if (AutomaticTaskState == TaskState.Idle)
|
||||
{
|
||||
_logger.LogDebug("Media Library changed, analyzis will start soon!");
|
||||
_queueTimer.Change(TimeSpan.FromMilliseconds(20000), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for timer callback to be completed.
|
||||
/// </summary>
|
||||
private void OnTimerCallback(object? state)
|
||||
{
|
||||
try
|
||||
{
|
||||
PerformAnalysis();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in PerformAnalysis");
|
||||
}
|
||||
|
||||
// Clean up
|
||||
_cancellationTokenSource = null;
|
||||
_autoTaskCompletEvent.Set();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for timer to be completed.
|
||||
/// </summary>
|
||||
private void PerformAnalysis()
|
||||
{
|
||||
_logger.LogInformation("Initiate automatic analysis task.");
|
||||
_autoTaskCompletEvent.Reset();
|
||||
|
||||
using (_cancellationTokenSource = new CancellationTokenSource())
|
||||
using (ScheduledTaskSemaphore.Acquire(_cancellationTokenSource.Token))
|
||||
{
|
||||
var seasonIds = new HashSet<Guid>(_seasonsToAnalyze);
|
||||
_seasonsToAnalyze.Clear();
|
||||
|
||||
_analyzeAgain = false;
|
||||
var progress = new Progress<double>();
|
||||
var modes = new List<AnalysisMode>();
|
||||
var tasklogger = _loggerFactory.CreateLogger("DefaultLogger");
|
||||
|
||||
if (_config.AutoDetectIntros)
|
||||
{
|
||||
modes.Add(AnalysisMode.Introduction);
|
||||
tasklogger = _loggerFactory.CreateLogger<DetectIntrosTask>();
|
||||
}
|
||||
|
||||
if (_config.AutoDetectCredits)
|
||||
{
|
||||
modes.Add(AnalysisMode.Credits);
|
||||
tasklogger = modes.Count == 2
|
||||
? _loggerFactory.CreateLogger<DetectIntrosCreditsTask>()
|
||||
: _loggerFactory.CreateLogger<DetectCreditsTask>();
|
||||
}
|
||||
|
||||
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
|
||||
modes,
|
||||
tasklogger,
|
||||
_loggerFactory,
|
||||
_libraryManager);
|
||||
|
||||
baseCreditAnalyzer.AnalyzeItems(progress, _cancellationTokenSource.Token, seasonIds);
|
||||
|
||||
// New item detected, start timer again
|
||||
if (_analyzeAgain && !_cancellationTokenSource.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Analyzing ended, but we need to analyze again!");
|
||||
StartTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Method to cancel the automatic task.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public static void CancelAutomaticTask(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_cancellationTokenSource.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
_autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
_queueTimer.Dispose();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_autoTaskCompletEvent.Dispose();
|
||||
}
|
||||
}
|
@ -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>
|
||||
@ -21,7 +21,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj" />
|
||||
<ProjectReference Include="..\IntroSkipper\IntroSkipper.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
@ -1,15 +1,18 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
/* These tests require that the host system has a version of FFmpeg installed
|
||||
* which supports both chromaprint and the "-fp_format raw" flag.
|
||||
*/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Xunit;
|
||||
using IntroSkipper.Analyzers;
|
||||
using IntroSkipper.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using Xunit;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||
namespace IntroSkipper.Tests;
|
||||
|
||||
public class TestAudioFingerprinting
|
||||
{
|
||||
@ -28,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]
|
||||
@ -83,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);
|
||||
}
|
||||
@ -124,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);
|
@ -1,9 +1,12 @@
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
namespace IntroSkipper.Tests;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using IntroSkipper.Analyzers;
|
||||
using IntroSkipper.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
|
||||
@ -15,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));
|
||||
|
||||
@ -40,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,10 +1,13 @@
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
namespace IntroSkipper.Tests;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using IntroSkipper.Analyzers;
|
||||
using IntroSkipper.Data;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
@ -1,7 +1,10 @@
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using IntroSkipper.Data;
|
||||
using Xunit;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||
namespace IntroSkipper.Tests;
|
||||
|
||||
public class TestTimeRanges
|
||||
{
|
@ -1,6 +1,9 @@
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
namespace IntroSkipper.Tests;
|
||||
|
||||
using IntroSkipper.Data;
|
||||
using Xunit;
|
||||
|
||||
public class TestFlags
|
@ -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
|
0
ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/build.sh → IntroSkipper.Tests/e2e_tests/build.sh
Executable file → Normal file
0
ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/build.sh → IntroSkipper.Tests/e2e_tests/build.sh
Executable file → Normal file
@ -1,8 +1,8 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
#
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper", "ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntroSkipper", "IntroSkipper\IntroSkipper.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper.Tests", "ConfusedPolarBear.Plugin.IntroSkipper.Tests\ConfusedPolarBear.Plugin.IntroSkipper.Tests.csproj", "{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntroSkipper.Tests", "IntroSkipper.Tests\IntroSkipper.Tests.csproj", "{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
218
IntroSkipper/Analyzers/BlackFrameAnalyzer.cs
Normal file
218
IntroSkipper/Analyzers/BlackFrameAnalyzer.cs
Normal file
@ -0,0 +1,218 @@
|
||||
// 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 IntroSkipper.Configuration;
|
||||
using IntroSkipper.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace IntroSkipper.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// 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(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 = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
||||
IReadOnlyList<QueuedEpisode> analysisQueue,
|
||||
AnalysisMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (mode != AnalysisMode.Credits)
|
||||
{
|
||||
throw new NotImplementedException("mode must equal Credits");
|
||||
}
|
||||
|
||||
var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
|
||||
|
||||
var searchStart = 0.0;
|
||||
|
||||
foreach (var episode in episodesWithoutIntros)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!AnalyzeChapters(episode, out var credit))
|
||||
{
|
||||
if (searchStart < _config.MinimumCreditsDuration)
|
||||
{
|
||||
searchStart = FindSearchStart(episode);
|
||||
}
|
||||
|
||||
credit = AnalyzeMediaFile(
|
||||
episode,
|
||||
searchStart,
|
||||
_config.BlackFrameMinimumPercentage);
|
||||
}
|
||||
|
||||
if (credit is null || !credit.Valid)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
episode.IsAnalyzed = true;
|
||||
await Plugin.Instance!.UpdateTimestampAsync(credit, mode).ConfigureAwait(false);
|
||||
searchStart = episode.Duration - credit.Start + _config.MinimumCreditsDuration;
|
||||
}
|
||||
|
||||
return analysisQueue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes an individual media file. Only public because of unit tests.
|
||||
/// </summary>
|
||||
/// <param name="episode">Media file to analyze.</param>
|
||||
/// <param name="searchStart">Search Start Piont.</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 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, _config.MinimumCreditsDuration);
|
||||
var start = TimeSpan.FromSeconds(upperLimit);
|
||||
var end = TimeSpan.FromSeconds(lowerLimit);
|
||||
var firstFrameTime = 0.0;
|
||||
|
||||
// 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)
|
||||
{
|
||||
// Analyze the middle two seconds from the current bisected range
|
||||
var midpoint = (start + end) / 2;
|
||||
var scanTime = episode.Duration - midpoint.TotalSeconds;
|
||||
var tr = new TimeRange(scanTime, scanTime + 2);
|
||||
|
||||
_logger.LogTrace(
|
||||
"{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]",
|
||||
episode.Name,
|
||||
episode.Duration,
|
||||
start,
|
||||
end,
|
||||
tr.Start,
|
||||
tr.End);
|
||||
|
||||
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum);
|
||||
_logger.LogTrace(
|
||||
"{Episode} at {Start} has {Count} black frames",
|
||||
episode.Name,
|
||||
tr.Start,
|
||||
frames.Length);
|
||||
|
||||
if (frames.Length == 0)
|
||||
{
|
||||
// Since no black frames were found, slide the range closer to the end
|
||||
start = midpoint - TimeSpan.FromSeconds(2);
|
||||
|
||||
if (midpoint - TimeSpan.FromSeconds(lowerLimit) < _maximumError)
|
||||
{
|
||||
lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), _config.MinimumCreditsDuration);
|
||||
|
||||
// Reset end for a new search with the increased duration
|
||||
end = TimeSpan.FromSeconds(lowerLimit);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Some black frames were found, slide the range closer to the start
|
||||
end = midpoint;
|
||||
firstFrameTime = frames[0].Time + scanTime;
|
||||
|
||||
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstFrameTime > 0)
|
||||
{
|
||||
return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,16 +1,19 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Configuration;
|
||||
using IntroSkipper.Data;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
|
||||
namespace IntroSkipper.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// Chapter name analyzer.
|
||||
@ -21,29 +24,32 @@ namespace ConfusedPolarBear.Plugin.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)
|
||||
{
|
||||
@ -52,22 +58,20 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
|
||||
|
||||
var skipRange = FindMatchingChapter(
|
||||
episode,
|
||||
new(Plugin.Instance.GetChapters(episode.EpisodeId)),
|
||||
Plugin.Instance!.GetChapters(episode.EpisodeId),
|
||||
expression,
|
||||
mode);
|
||||
|
||||
if (skipRange is null)
|
||||
if (skipRange is null || !skipRange.Valid)
|
||||
{
|
||||
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>
|
||||
@ -81,7 +85,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
|
||||
/// <returns>Intro object containing skippable time range, or null if no chapter matched.</returns>
|
||||
public Segment? FindMatchingChapter(
|
||||
QueuedEpisode episode,
|
||||
Collection<ChapterInfo> chapters,
|
||||
IReadOnlyList<ChapterInfo> chapters,
|
||||
string expression,
|
||||
AnalysisMode mode)
|
||||
{
|
||||
@ -91,11 +95,11 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
|
||||
return null;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
var reversed = mode != AnalysisMode.Introduction;
|
||||
var creditDuration = episode.IsMovie ? _config.MaximumMovieCreditsDuration : _config.MaximumCreditsDuration;
|
||||
var reversed = mode == AnalysisMode.Credits;
|
||||
var (minDuration, maxDuration) = reversed
|
||||
? (config.MinimumCreditsDuration, config.MaximumCreditsDuration)
|
||||
: (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)
|
||||
@ -132,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)
|
@ -1,95 +1,58 @@
|
||||
// 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 System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Threading;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Configuration;
|
||||
using IntroSkipper.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
|
||||
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
|
||||
{
|
||||
@ -117,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(
|
||||
@ -186,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>
|
||||
@ -296,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.
|
||||
@ -306,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);
|
||||
|
||||
@ -371,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;
|
||||
}
|
||||
@ -388,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);
|
||||
}
|
@ -1,8 +1,12 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using System.Threading.Tasks;
|
||||
using IntroSkipper.Data;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
|
||||
namespace IntroSkipper.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// Media file analyzer interface.
|
||||
@ -16,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,8 +1,12 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using IntroSkipper.Data;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
namespace IntroSkipper.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin configuration.
|
||||
@ -18,11 +22,6 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
|
||||
// ===== Analysis settings =====
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the max degree of parallelism used when analyzing episodes.
|
||||
/// </summary>
|
||||
public int MaxParallelism { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the comma separated list of library names to analyze.
|
||||
/// </summary>
|
||||
@ -33,20 +32,20 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
public bool SelectAllLibraries { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether movies should be analyzed.
|
||||
/// </summary>
|
||||
public bool AnalyzeMovies { get; set; }
|
||||
|
||||
/// <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.
|
||||
@ -61,24 +60,44 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether analysis will use Chromaprint to determine fingerprints.
|
||||
/// </summary>
|
||||
public bool UseChromaprint { get; set; } = true;
|
||||
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>
|
||||
@ -107,7 +126,12 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// <summary>
|
||||
/// Gets or sets the upper limit (in seconds) on the length of each episode's audio track that will be analyzed when searching for ending credits.
|
||||
/// </summary>
|
||||
public int MaximumCreditsDuration { get; set; } = 300;
|
||||
public int MaximumCreditsDuration { get; set; } = 450;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the upper limit (in seconds) on the length of a movie segment that will be analyzed when searching for ending credits.
|
||||
/// </summary>
|
||||
public int MaximumMovieCreditsDuration { get; set; } = 900;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
|
||||
@ -118,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>
|
||||
@ -178,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>
|
||||
@ -227,20 +278,25 @@ 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";
|
||||
public string AutoSkipNotificationText { get; set; } = "Segment skipped";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification text sent after automatically skipping credits.
|
||||
/// Gets or sets the max degree of parallelism used when analyzing episodes.
|
||||
/// </summary>
|
||||
public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
|
||||
public int MaxParallelism { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of threads for an ffmpeg process.
|
||||
/// Gets or sets the number of threads for a ffmpeg process.
|
||||
/// </summary>
|
||||
public int ProcessThreads { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the relative priority for an ffmpeg process.
|
||||
/// Gets or sets the relative priority for a ffmpeg process.
|
||||
/// </summary>
|
||||
public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the ManifestUrl is self-managed, e.g. for mainland China.
|
||||
/// </summary>
|
||||
public bool OverrideManifestUrl { get; set; }
|
||||
}
|
61
IntroSkipper/Configuration/UserInterfaceConfiguration.cs
Normal file
61
IntroSkipper/Configuration/UserInterfaceConfiguration.cs
Normal file
@ -0,0 +1,61 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
namespace IntroSkipper.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// User interface configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="UserInterfaceConfiguration"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="visible">Skip button visibility.</param>
|
||||
/// <param name="introText">Skip button intro text.</param>
|
||||
/// <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, bool autoSkipRecap, bool autoSkipPreview, string clientList)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to show the skip intro button.
|
||||
/// </summary>
|
||||
public bool SkipButtonVisible { get; set; } = visible;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text to display in the skip intro button in introduction mode.
|
||||
/// </summary>
|
||||
public string SkipButtonIntroText { get; set; } = introText;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text to display in the skip intro button in end credits mode.
|
||||
/// </summary>
|
||||
public string SkipButtonEndCreditsText { get; set; } = creditsText;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether auto skip intro.
|
||||
/// </summary>
|
||||
public bool AutoSkip { get; set; } = autoSkip;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether auto skip credits.
|
||||
/// </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>
|
||||
public string ClientList { get; set; } = clientList;
|
||||
}
|
1672
IntroSkipper/Configuration/configPage.html
Normal file
1672
IntroSkipper/Configuration/configPage.html
Normal file
File diff suppressed because it is too large
Load Diff
597
IntroSkipper/Configuration/inject.js
Normal file
597
IntroSkipper/Configuration/inject.js
Normal file
@ -0,0 +1,597 @@
|
||||
const introSkipper = {
|
||||
originalFetch: window.fetch.bind(window),
|
||||
originalXHROpen: XMLHttpRequest.prototype.open,
|
||||
d: (msg) => console.debug("[intro skipper] ", msg),
|
||||
setup() {
|
||||
const self = this;
|
||||
this.initializeState();
|
||||
this.initializeObserver();
|
||||
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);
|
||||
this.d("Registered hooks");
|
||||
},
|
||||
initializeState() {
|
||||
Object.assign(this, {
|
||||
allowEnter: true,
|
||||
skipSegments: {},
|
||||
videoPlayer: null,
|
||||
skipButton: null,
|
||||
osdElement: null,
|
||||
skipperData: null,
|
||||
currentEpisodeId: null,
|
||||
injectMetadata: false,
|
||||
});
|
||||
},
|
||||
initializeObserver() {
|
||||
this.observer = new MutationObserver((mutations) => {
|
||||
const actionSheet =
|
||||
mutations[mutations.length - 1].target.querySelector(".actionSheet");
|
||||
if (
|
||||
actionSheet &&
|
||||
!actionSheet.querySelector(`[data-id="${"introskipperMenu"}"]`)
|
||||
)
|
||||
this.injectIntroSkipperOptions(actionSheet);
|
||||
});
|
||||
},
|
||||
fetchWrapper(resource, options) {
|
||||
const response = this.originalFetch(resource, options);
|
||||
const url = new URL(resource);
|
||||
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) {
|
||||
try {
|
||||
this.skipSegments = await this.secureFetch(
|
||||
`Episode/${id}/IntroSkipperSegments`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.d(`Error fetching skip segments: ${error.message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
async processMetadata(url) {
|
||||
const id = this.extractId(url);
|
||||
if (id) {
|
||||
try {
|
||||
this.skipperData = await this.secureFetch(`Episode/${id}/Timestamps`);
|
||||
if (this.skipperData) {
|
||||
this.currentEpisodeId = id;
|
||||
requestAnimationFrame(() => {
|
||||
const metadataFormFields = document.querySelector(
|
||||
".metadataFormFields",
|
||||
);
|
||||
metadataFormFields && this.injectSkipperFields(metadataFormFields);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error processing", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
extractId(searchString) {
|
||||
const startIndex = searchString.indexOf("Items/") + 6;
|
||||
const endIndex = searchString.indexOf("/", startIndex);
|
||||
return endIndex !== -1
|
||||
? searchString.substring(startIndex, endIndex)
|
||||
: searchString.substring(startIndex);
|
||||
},
|
||||
/**
|
||||
* Event handler that runs whenever the current view changes.
|
||||
* Used to detect the start of video playback.
|
||||
*/
|
||||
viewShow() {
|
||||
const location = window.location.hash;
|
||||
this.d(`Location changed to ${location}`);
|
||||
this.allowEnter = true;
|
||||
this.injectMetadata = /#\/(tv|details|home|search)/.test(location);
|
||||
if (location === "#/video") {
|
||||
this.injectCss();
|
||||
this.injectButton();
|
||||
this.videoPlayer = document.querySelector("video");
|
||||
if (this.videoPlayer) {
|
||||
this.d("Hooking video timeupdate");
|
||||
this.videoPlayer.addEventListener(
|
||||
"timeupdate",
|
||||
this.videoPositionChanged,
|
||||
);
|
||||
this.osdElement = document.querySelector("div.videoOsdBottom");
|
||||
this.observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Injects the CSS used by the skip intro button.
|
||||
* Calling this function is a no-op if the CSS has already been injected.
|
||||
*/
|
||||
injectCss() {
|
||||
if (document.querySelector("style#introSkipperCss")) {
|
||||
this.d("CSS already added");
|
||||
return;
|
||||
}
|
||||
this.d("Adding CSS");
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.id = "introSkipperCss";
|
||||
styleElement.textContent = `
|
||||
:root {
|
||||
--rounding: 4px;
|
||||
--accent: 0, 164, 220;
|
||||
}
|
||||
#skipIntro.upNextContainer {
|
||||
width: unset;
|
||||
margin: unset;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
`;
|
||||
document.querySelector("head").appendChild(styleElement);
|
||||
},
|
||||
/**
|
||||
* Inject the skip intro button into the video player.
|
||||
* Calling this function is a no-op if the CSS has already been injected.
|
||||
*/
|
||||
async injectButton() {
|
||||
// Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
|
||||
const preExistingButton = document.querySelector("div.skipIntro");
|
||||
if (preExistingButton) {
|
||||
preExistingButton.style.display = "none";
|
||||
}
|
||||
if (document.querySelector(".btnSkipIntro.injected")) {
|
||||
this.d("Button already added");
|
||||
this.skipButton = document.querySelector("#skipIntro");
|
||||
return;
|
||||
}
|
||||
const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
|
||||
if (!config.SkipButtonVisible) {
|
||||
this.d("Not adding button: not visible");
|
||||
return;
|
||||
}
|
||||
this.d("Adding button");
|
||||
this.skipButton = document.createElement("div");
|
||||
this.skipButton.id = "skipIntro";
|
||||
this.skipButton.classList.add("hide", "upNextContainer");
|
||||
this.skipButton.addEventListener("click", this.doSkip.bind(this));
|
||||
this.skipButton.addEventListener("keydown", this.eventHandler.bind(this));
|
||||
this.skipButton.innerHTML = `
|
||||
<button is="emby-button" type="button" class="btnSkipIntro injected">
|
||||
<span id="btnSkipSegmentText"></span>
|
||||
<span class="material-icons skip_next"></span>
|
||||
</button>
|
||||
`;
|
||||
this.skipButton.dataset.Introduction = config.SkipButtonIntroText;
|
||||
this.skipButton.dataset.Credits = config.SkipButtonEndCreditsText;
|
||||
const controls = document.querySelector("div#videoOsdPage");
|
||||
controls.appendChild(this.skipButton);
|
||||
},
|
||||
/** Tests if the OSD controls are visible. */
|
||||
osdVisible() {
|
||||
return this.osdElement
|
||||
? !this.osdElement.classList.contains("hide")
|
||||
: false;
|
||||
},
|
||||
/** Get the currently playing skippable segment. */
|
||||
getCurrentSegment(position) {
|
||||
for (const key in this.skipSegments) {
|
||||
const segment = this.skipSegments[key];
|
||||
if (
|
||||
(position > segment.ShowSkipPromptAt &&
|
||||
position < segment.HideSkipPromptAt) ||
|
||||
(this.osdVisible() &&
|
||||
position > segment.IntroStart &&
|
||||
position < segment.IntroEnd - 3)
|
||||
) {
|
||||
segment["SegmentType"] = key;
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
return { SegmentType: "None" };
|
||||
},
|
||||
overrideBlur(button) {
|
||||
if (!button.originalBlur) {
|
||||
button.originalBlur = button.blur;
|
||||
button.blur = function () {
|
||||
if (!this.contains(document.activeElement)) {
|
||||
this.originalBlur();
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
/** Playback position changed, check if the skip button needs to be displayed. */
|
||||
videoPositionChanged() {
|
||||
if (!this.skipButton || !this.allowEnter) return;
|
||||
const { SegmentType: segmentType } = this.getCurrentSegment(this.videoPlayer.currentTime);
|
||||
if (
|
||||
segmentType === "None" ||
|
||||
this.currentOption === "Off"
|
||||
) {
|
||||
this.hideSkipButton();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.currentOption === "Automatically Skip" ||
|
||||
(this.currentOption === "Button w/ auto PiP" &&
|
||||
document.pictureInPictureElement)
|
||||
) {
|
||||
this.doSkip();
|
||||
return;
|
||||
}
|
||||
const button = this.skipButton.querySelector(".emby-button");
|
||||
this.skipButton.querySelector("#btnSkipSegmentText").textContent =
|
||||
this.skipButton.dataset[segmentType];
|
||||
if (!this.skipButton.classList.contains("hide")) {
|
||||
if (!this.osdVisible() && !button.contains(document.activeElement)) {
|
||||
button.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
this.skipButton.classList.remove("hide");
|
||||
requestAnimationFrame(() => {
|
||||
this.skipButton.classList.add("show");
|
||||
this.overrideBlur(button);
|
||||
button.focus();
|
||||
});
|
||||
});
|
||||
},
|
||||
hideSkipButton() {
|
||||
if (this.skipButton.classList.contains("show")) {
|
||||
this.skipButton.classList.remove("show");
|
||||
const button = this.skipButton.querySelector(".emby-button");
|
||||
button.addEventListener(
|
||||
"transitionend",
|
||||
() => {
|
||||
this.skipButton.classList.add("hide");
|
||||
if (this.osdVisible()) {
|
||||
this.osdElement.querySelector("button.btnPause").focus();
|
||||
} else {
|
||||
button.originalBlur();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
/** Seeks to the end of the intro. */
|
||||
doSkip() {
|
||||
if (!this.allowEnter) return;
|
||||
const segment = this.getCurrentSegment(this.videoPlayer.currentTime);
|
||||
if (segment.SegmentType === "None") {
|
||||
console.warn("[intro skipper] doSkip() called without an active segment");
|
||||
return;
|
||||
}
|
||||
this.d(`Skipping ${segment.SegmentType}`);
|
||||
const seekedHandler = () => {
|
||||
this.videoPlayer.removeEventListener("seeked", seekedHandler);
|
||||
setTimeout(() => {
|
||||
this.allowEnter = true;
|
||||
}, 700);
|
||||
};
|
||||
this.videoPlayer.addEventListener("seeked", seekedHandler);
|
||||
this.videoPlayer.currentTime = segment.IntroEnd;
|
||||
this.hideSkipButton();
|
||||
},
|
||||
createButton(ref, id, innerHTML, clickHandler) {
|
||||
const button = ref.cloneNode(true);
|
||||
button.setAttribute("data-id", id);
|
||||
button.innerHTML = innerHTML;
|
||||
button.addEventListener("click", clickHandler);
|
||||
return button;
|
||||
},
|
||||
closeSubmenu(fullscreen) {
|
||||
document.querySelector(".dialogContainer").remove();
|
||||
document.querySelector(".dialogBackdrop").remove();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Control" }));
|
||||
if (!fullscreen) return;
|
||||
document.removeEventListener("keydown", this.handleEscapeKey);
|
||||
document.querySelector(".btnVideoOsdSettings").focus();
|
||||
},
|
||||
openSubmenu(ref, menu) {
|
||||
const options = [
|
||||
"Show Button",
|
||||
"Button w/ auto PiP",
|
||||
"Automatically Skip",
|
||||
"Off",
|
||||
];
|
||||
const submenu = menu.cloneNode(true);
|
||||
const scroller = submenu.querySelector(".actionSheetScroller");
|
||||
scroller.innerHTML = "";
|
||||
for (const option of options) {
|
||||
if (option !== "Button w/ auto PiP" || document.pictureInPictureEnabled) {
|
||||
const button = this.createButton(
|
||||
ref,
|
||||
`introskipper-${option.toLowerCase().replace(" ", "-")}`,
|
||||
`<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons check" aria-hidden="true" style="visibility:${option === this.currentOption ? "visible" : "hidden"};"></span><div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">${option}</div></div>`,
|
||||
() => this.selectOption(option),
|
||||
);
|
||||
scroller.appendChild(button);
|
||||
}
|
||||
}
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "dialogBackdrop dialogBackdropOpened";
|
||||
document.body.append(backdrop, submenu);
|
||||
const actionSheet = submenu.querySelector(".actionSheet");
|
||||
if (actionSheet.classList.contains("actionsheet-not-fullscreen")) {
|
||||
this.adjustPosition(
|
||||
actionSheet,
|
||||
document.querySelector(".btnVideoOsdSettings"),
|
||||
);
|
||||
submenu.addEventListener("click", () => this.closeSubmenu(false));
|
||||
} else {
|
||||
submenu
|
||||
.querySelector(".btnCloseActionSheet")
|
||||
.addEventListener("click", () => this.closeSubmenu(true));
|
||||
scroller.addEventListener("click", () => this.closeSubmenu(true));
|
||||
document.addEventListener("keydown", this.handleEscapeKey);
|
||||
setTimeout(() => scroller.firstElementChild.focus(), 240);
|
||||
}
|
||||
},
|
||||
selectOption(option) {
|
||||
this.currentOption = option;
|
||||
localStorage.setItem("introskipperOption", option);
|
||||
this.d(`Introskipper option selected and saved: ${option}`);
|
||||
},
|
||||
isAutoSkipLocked(config) {
|
||||
const isAutoSkip = config.AutoSkip && config.AutoSkipCredits;
|
||||
const isAutoSkipClient = new Set(config.ClientList.split(",")).has(
|
||||
ApiClient.appName(),
|
||||
);
|
||||
return isAutoSkip || (config.SkipButtonVisible && isAutoSkipClient);
|
||||
},
|
||||
async injectIntroSkipperOptions(actionSheet) {
|
||||
if (!this.skipButton) return;
|
||||
const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
|
||||
if (this.isAutoSkipLocked(config)) {
|
||||
this.d("Auto skip enforced by server");
|
||||
return;
|
||||
}
|
||||
const statsButton = actionSheet.querySelector('[data-id="stats"]');
|
||||
if (!statsButton) return;
|
||||
const menuItem = this.createButton(
|
||||
statsButton,
|
||||
"introskipperMenu",
|
||||
`<div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">Intro Skipper</div></div><div class="listItemAside actionSheetItemAsideText">${this.currentOption}</div>`,
|
||||
() =>
|
||||
this.openSubmenu(statsButton, actionSheet.closest(".dialogContainer")),
|
||||
);
|
||||
const originalWidth = actionSheet.offsetWidth;
|
||||
statsButton.before(menuItem);
|
||||
if (actionSheet.classList.contains("actionsheet-not-fullscreen"))
|
||||
this.adjustPosition(actionSheet, menuItem, originalWidth);
|
||||
},
|
||||
adjustPosition(element, reference, originalWidth) {
|
||||
if (originalWidth) {
|
||||
const currentTop = Number.parseInt(element.style.top, 10) || 0;
|
||||
element.style.top = `${currentTop - reference.offsetHeight}px`;
|
||||
const newWidth = Math.max(reference.offsetWidth - originalWidth, 0);
|
||||
const originalLeft = Number.parseInt(element.style.left, 10) || 0;
|
||||
element.style.left = `${originalLeft - newWidth / 2}px`;
|
||||
} else {
|
||||
const rect = reference.getBoundingClientRect();
|
||||
element.style.left = `${Math.min(rect.left - (element.offsetWidth - rect.width) / 2, window.innerWidth - element.offsetWidth - 10)}px`;
|
||||
element.style.top = `${rect.top - element.offsetHeight + rect.height}px`;
|
||||
}
|
||||
},
|
||||
injectSkipperFields(metadataFormFields) {
|
||||
const skipperFields = document.createElement("div");
|
||||
skipperFields.className = "detailSection introskipperSection";
|
||||
skipperFields.innerHTML = `
|
||||
<h2>Intro Skipper</h2>
|
||||
<div class="inlineForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label>
|
||||
<input type="text" id="introStartDisplay" class="emby-input custom-time-input" readonly>
|
||||
<input type="number" id="introStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label>
|
||||
<input type="text" id="introEndDisplay" class="emby-input custom-time-input" readonly>
|
||||
<input type="number" id="introEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="inlineForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
|
||||
<input type="text" id="creditsStartDisplay" class="emby-input custom-time-input" readonly>
|
||||
<input type="number" id="creditsStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="creditsEnd">Credits End</label>
|
||||
<input type="text" id="creditsEndDisplay" class="emby-input custom-time-input" readonly>
|
||||
<input type="number" id="creditsEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
metadataFormFields
|
||||
.querySelector("#metadataSettingsCollapsible")
|
||||
.insertAdjacentElement("afterend", skipperFields);
|
||||
this.attachSaveListener(metadataFormFields);
|
||||
this.updateSkipperFields(skipperFields);
|
||||
this.setTimeInputs(skipperFields);
|
||||
},
|
||||
updateSkipperFields(skipperFields) {
|
||||
const { Introduction = {}, Credits = {} } = this.skipperData;
|
||||
skipperFields.querySelector("#introStartEdit").value =
|
||||
Introduction.Start || 0;
|
||||
skipperFields.querySelector("#introEndEdit").value = Introduction.End || 0;
|
||||
skipperFields.querySelector("#creditsStartEdit").value = Credits.Start || 0;
|
||||
skipperFields.querySelector("#creditsEndEdit").value = Credits.End || 0;
|
||||
},
|
||||
attachSaveListener(metadataFormFields) {
|
||||
const saveButton = metadataFormFields.querySelector(
|
||||
".formDialogFooter .btnSave",
|
||||
);
|
||||
if (saveButton) {
|
||||
saveButton.addEventListener("click", this.saveSkipperData.bind(this));
|
||||
} else {
|
||||
console.error("Save button not found");
|
||||
}
|
||||
},
|
||||
setTimeInputs(skipperFields) {
|
||||
const inputContainers = skipperFields.querySelectorAll(".inputContainer");
|
||||
for (const container of inputContainers) {
|
||||
const displayInput = container.querySelector('[id$="Display"]');
|
||||
const editInput = container.querySelector('[id$="Edit"]');
|
||||
displayInput.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
this.switchToEdit(displayInput, editInput);
|
||||
});
|
||||
editInput.addEventListener("blur", () =>
|
||||
this.switchToDisplay(displayInput, editInput),
|
||||
);
|
||||
displayInput.value = this.formatTime(
|
||||
Number.parseFloat(editInput.value) || 0,
|
||||
);
|
||||
}
|
||||
},
|
||||
formatTime(totalSeconds) {
|
||||
if (!totalSeconds) return "0 seconds";
|
||||
const totalRoundedSeconds = Math.round(totalSeconds);
|
||||
const hours = Math.floor(totalRoundedSeconds / 3600);
|
||||
const minutes = Math.floor((totalRoundedSeconds % 3600) / 60);
|
||||
const seconds = totalRoundedSeconds % 60;
|
||||
const result = [];
|
||||
if (hours) result.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
|
||||
if (minutes) result.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
|
||||
if (seconds || !result.length)
|
||||
result.push(`${seconds} second${seconds !== 1 ? "s" : ""}`);
|
||||
return result.join(" ");
|
||||
},
|
||||
switchToEdit(displayInput, editInput) {
|
||||
displayInput.style.display = "none";
|
||||
editInput.style.display = "";
|
||||
editInput.focus();
|
||||
},
|
||||
switchToDisplay(displayInput, editInput) {
|
||||
editInput.style.display = "none";
|
||||
displayInput.style.display = "";
|
||||
displayInput.value = this.formatTime(
|
||||
Number.parseFloat(editInput.value) || 0,
|
||||
);
|
||||
},
|
||||
async saveSkipperData() {
|
||||
const newTimestamps = {
|
||||
Introduction: {
|
||||
Start: Number.parseFloat(
|
||||
document.getElementById("introStartEdit").value || 0,
|
||||
),
|
||||
End: Number.parseFloat(
|
||||
document.getElementById("introEndEdit").value || 0,
|
||||
),
|
||||
},
|
||||
Credits: {
|
||||
Start: Number.parseFloat(
|
||||
document.getElementById("creditsStartEdit").value || 0,
|
||||
),
|
||||
End: Number.parseFloat(
|
||||
document.getElementById("creditsEndEdit").value || 0,
|
||||
),
|
||||
},
|
||||
};
|
||||
const { Introduction = {}, Credits = {} } = this.skipperData;
|
||||
if (
|
||||
newTimestamps.Introduction.Start !== (Introduction.Start || 0) ||
|
||||
newTimestamps.Introduction.End !== (Introduction.End || 0) ||
|
||||
newTimestamps.Credits.Start !== (Credits.Start || 0) ||
|
||||
newTimestamps.Credits.End !== (Credits.End || 0)
|
||||
) {
|
||||
const response = await this.secureFetch(
|
||||
`Episode/${this.currentEpisodeId}/Timestamps`,
|
||||
"POST",
|
||||
JSON.stringify(newTimestamps),
|
||||
);
|
||||
this.d(
|
||||
response.ok
|
||||
? "Timestamps updated successfully"
|
||||
: "Failed to update timestamps:",
|
||||
response.status,
|
||||
);
|
||||
} else {
|
||||
this.d("Timestamps have not changed, skipping update");
|
||||
}
|
||||
},
|
||||
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
|
||||
async secureFetch(url, method = "GET", body = null) {
|
||||
const response = await fetch(`${ApiClient.serverAddress()}/${url}`, {
|
||||
method,
|
||||
headers: Object.assign(
|
||||
{ Authorization: `MediaBrowser Token=${ApiClient.accessToken()}` },
|
||||
method === "POST" ? { "Content-Type": "application/json" } : {},
|
||||
),
|
||||
body,
|
||||
});
|
||||
return response.ok
|
||||
? method === "POST"
|
||||
? response
|
||||
: response.json()
|
||||
: response.status === 404
|
||||
? null
|
||||
: console.error(`Error ${response.status} from ${url}`) || null;
|
||||
},
|
||||
/** Handle keydown events. */
|
||||
eventHandler(e) {
|
||||
if (e.key !== "Enter") return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.doSkip();
|
||||
},
|
||||
handleEscapeKey(e) {
|
||||
if (e.key === "Escape" || e.keyCode === 461 || e.keyCode === 10009) {
|
||||
e.stopPropagation();
|
||||
this.closeSubmenu(true);
|
||||
}
|
||||
},
|
||||
};
|
||||
introSkipper.setup();
|
274
IntroSkipper/Controllers/SkipIntroController.cs
Normal file
274
IntroSkipper/Controllers/SkipIntroController.cs
Normal file
@ -0,0 +1,274 @@
|
||||
// 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.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;
|
||||
|
||||
/// <summary>
|
||||
/// Skip intro controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateManager) : ControllerBase
|
||||
{
|
||||
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format.
|
||||
/// </summary>
|
||||
/// <param name="id">ID of the episode. Required.</param>
|
||||
/// <param name="mode">Timestamps to return. Optional. Defaults to Introduction for backwards compatibility.</param>
|
||||
/// <response code="200">Episode contains an intro.</response>
|
||||
/// <response code="404">Failed to find an intro in the provided episode.</response>
|
||||
/// <returns>Detected intro.</returns>
|
||||
[HttpGet("Episode/{id}/IntroTimestamps")]
|
||||
[HttpGet("Episode/{id}/IntroTimestamps/v1")]
|
||||
public ActionResult<Intro> GetIntroTimestamps(
|
||||
[FromRoute] Guid id,
|
||||
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
|
||||
{
|
||||
var intros = GetIntros(id);
|
||||
if (!intros.TryGetValue(mode, out var intro))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return intro;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the timestamps for the provided episode.
|
||||
/// </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 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 is not Episode and not Movie)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (timestamps == null)
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
var segmentTypes = new[]
|
||||
{
|
||||
(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);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamps for the provided episode.
|
||||
/// </summary>
|
||||
/// <param name="id">Episode ID.</param>
|
||||
/// <response code="200">Sucess.</response>
|
||||
/// <response code="404">Given ID is not an Episode.</response>
|
||||
/// <returns>Episode Timestamps.</returns>
|
||||
[HttpGet("Episode/{Id}/Timestamps")]
|
||||
[ActionName("UpdateTimestamps")]
|
||||
public ActionResult<TimeStamps> GetTimestamps([FromRoute] Guid id)
|
||||
{
|
||||
// only get return content for episodes
|
||||
var rawItem = Plugin.Instance!.GetItem(id);
|
||||
if (rawItem is not Episode and not Movie)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var times = new TimeStamps();
|
||||
var segments = Plugin.Instance!.GetTimestamps(id);
|
||||
|
||||
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
|
||||
{
|
||||
times.Introduction = introSegment;
|
||||
}
|
||||
|
||||
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment))
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a dictionary of all skippable segments.
|
||||
/// </summary>
|
||||
/// <param name="id">Media ID.</param>
|
||||
/// <response code="200">Skippable segments dictionary.</response>
|
||||
/// <returns>Dictionary of skippable segments.</returns>
|
||||
[HttpGet("Episode/{id}/IntroSkipperSegments")]
|
||||
public ActionResult<Dictionary<AnalysisMode, Intro>> GetSkippableSegments([FromRoute] Guid id)
|
||||
{
|
||||
var segments = GetIntros(id);
|
||||
var result = new Dictionary<AnalysisMode, Intro>();
|
||||
|
||||
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
|
||||
{
|
||||
result[AnalysisMode.Introduction] = introSegment;
|
||||
}
|
||||
|
||||
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment))
|
||||
{
|
||||
result[AnalysisMode.Credits] = creditSegment;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Lookup and return the skippable timestamps for the provided item.</summary>
|
||||
/// <param name="id">Unique identifier of this episode.</param>
|
||||
/// <returns>Intro object if the provided item has an intro, null otherwise.</returns>
|
||||
internal static Dictionary<AnalysisMode, Intro> GetIntros(Guid id)
|
||||
{
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
// 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 - MIN_REMAINING_TIME;
|
||||
}
|
||||
else
|
||||
{
|
||||
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
|
||||
segment.HideSkipPromptAt = Math.Min(
|
||||
segment.IntroStart + config.HidePromptAdjustment,
|
||||
segment.IntroEnd - MIN_REMAINING_TIME);
|
||||
}
|
||||
|
||||
intros[mode] = segment;
|
||||
}
|
||||
|
||||
return intros;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erases all previously discovered introduction timestamps.
|
||||
/// </summary>
|
||||
/// <param name="mode">Mode.</param>
|
||||
/// <param name="eraseCache">Erase cache.</param>
|
||||
/// <response code="204">Operation successful.</response>
|
||||
/// <returns>No content.</returns>
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[HttpPost("Intros/EraseTimestamps")]
|
||||
public async Task<ActionResult> ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false)
|
||||
{
|
||||
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)
|
||||
{
|
||||
await Task.Run(() => FFmpegWrapper.DeleteCacheFiles(mode)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user interface configuration.
|
||||
/// </summary>
|
||||
/// <response code="200">UserInterfaceConfiguration returned.</response>
|
||||
/// <returns>UserInterfaceConfiguration.</returns>
|
||||
[HttpGet]
|
||||
[Route("Intros/UserInterfaceConfiguration")]
|
||||
public ActionResult<UserInterfaceConfiguration> GetUserInterfaceConfiguration()
|
||||
{
|
||||
var config = Plugin.Instance!.Configuration;
|
||||
return new UserInterfaceConfiguration(
|
||||
config.SkipButtonEnabled,
|
||||
config.SkipButtonIntroText,
|
||||
config.SkipButtonEndCreditsText,
|
||||
config.AutoSkip,
|
||||
config.AutoSkipCredits,
|
||||
config.AutoSkipRecap,
|
||||
config.AutoSkipPreview,
|
||||
config.ClientList);
|
||||
}
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Helper;
|
||||
using IntroSkipper.Data;
|
||||
using IntroSkipper.Helper;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@ -12,7 +15,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
|
||||
namespace IntroSkipper.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Troubleshooting controller.
|
@ -1,15 +1,22 @@
|
||||
// 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.Net.Mime;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
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;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
|
||||
namespace IntroSkipper.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis.
|
||||
@ -18,13 +25,15 @@ namespace ConfusedPolarBear.Plugin.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.
|
||||
@ -47,7 +56,7 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
|
||||
var seasonNumber = first.SeasonNumber;
|
||||
if (!showSeasons.TryGetValue(seriesId, out var showInfo))
|
||||
{
|
||||
showInfo = new ShowInfos { SeriesName = first.SeriesName, ProductionYear = GetProductionYear(seriesId), LibraryName = GetLibraryName(seriesId), Seasons = [] };
|
||||
showInfo = new ShowInfos { SeriesName = first.SeriesName, ProductionYear = GetProductionYear(seriesId), LibraryName = GetLibraryName(seriesId), IsMovie = first.IsMovie, Seasons = [] };
|
||||
showSeasons[seriesId] = showInfo;
|
||||
}
|
||||
|
||||
@ -65,6 +74,7 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
|
||||
SeriesName = kvp.Value.SeriesName,
|
||||
ProductionYear = kvp.Value.ProductionYear,
|
||||
LibraryName = kvp.Value.LibraryName,
|
||||
IsMovie = kvp.Value.IsMovie,
|
||||
Seasons = kvp.Value.Seasons
|
||||
.OrderBy(s => s.Value)
|
||||
.ToDictionary(s => s.Key, s => s.Value)
|
||||
@ -74,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>
|
||||
@ -172,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)
|
||||
{
|
||||
@ -190,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();
|
||||
}
|
30
IntroSkipper/Data/AnalysisMode.cs
Normal file
30
IntroSkipper/Data/AnalysisMode.cs
Normal file
@ -0,0 +1,30 @@
|
||||
// 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 AnalysisMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Detect introduction sequences.
|
||||
/// </summary>
|
||||
Introduction,
|
||||
|
||||
/// <summary>
|
||||
/// 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,4 +1,7 @@
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// A frame of video that partially (or entirely) consists of black pixels.
|
@ -1,6 +1,9 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Episode name and internal ID as returned by the visualization controller.
|
@ -1,6 +1,9 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Exception raised when an error is encountered analyzing audio.
|
@ -1,7 +1,10 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Result of fingerprinting and analyzing two episodes in a season.
|
@ -1,6 +1,9 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Support bundle warning.
|
@ -1,6 +1,9 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Episode queued for analysis.
|
||||
@ -22,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>
|
||||
@ -47,6 +50,16 @@ public class QueuedEpisode
|
||||
/// </summary>
|
||||
public bool IsAnime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an item is a movie.
|
||||
/// </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>
|
@ -1,9 +1,11 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Result of fingerprinting and analyzing two episodes in a season.
|
||||
@ -31,8 +33,8 @@ public class Segment
|
||||
public Segment(Guid episode)
|
||||
{
|
||||
EpisodeId = episode;
|
||||
Start = 0;
|
||||
End = 0;
|
||||
Start = 0.0;
|
||||
End = 0.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -86,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);
|
||||
}
|
||||
}
|
@ -1,7 +1,10 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers
|
||||
namespace IntroSkipper.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains information about a show.
|
||||
@ -23,6 +26,11 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers
|
||||
/// </summary>
|
||||
public required string LibraryName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether its a movie.
|
||||
/// </summary>
|
||||
public required bool IsMovie { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Seasons of the show.
|
||||
/// </summary>
|
@ -1,6 +1,9 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
#pragma warning disable CA1036 // Override methods on comparable types
|
||||
|
@ -1,7 +1,10 @@
|
||||
using System;
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Time range helpers.
|
@ -1,4 +1,7 @@
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
namespace IntroSkipper.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of fingerprinting and analyzing two episodes in a season.
|
||||
@ -15,5 +18,15 @@ namespace ConfusedPolarBear.Plugin.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>();
|
||||
}
|
||||
}
|
@ -1,4 +1,7 @@
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
namespace IntroSkipper.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Warning manager.
|
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));
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user