Compare commits
156 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
54ade16186 | ||
|
efae66a541 | ||
|
e646ea874a | ||
|
7cd1b705fc | ||
|
9162dbcc76 | ||
|
e75bf05819 | ||
|
282c975a0b | ||
|
4e7d325fc2 | ||
|
a666c61bbb | ||
|
44d5a24e03 | ||
|
c52ad5dc05 | ||
|
a606203231 | ||
|
2adff3590a | ||
|
c43ad03635 | ||
|
3fc11c7e02 | ||
|
cb08420e8d | ||
|
2678c69e38 | ||
|
c0513b1f22 | ||
|
89ae0b41e3 | ||
|
3ffb71cd4c | ||
|
08d1e6e0b5 | ||
|
507bb64d45 | ||
|
9922f97bc8 | ||
|
f8dba7f005 | ||
|
8bf31b6ad1 | ||
|
f444ca018b | ||
|
8a27f15fe1 | ||
|
98c9c03f28 | ||
|
d2d0714e66 | ||
|
c397a180e8 | ||
|
6d9cacd2ec | ||
|
e619114ec3 | ||
|
71f1148f07 | ||
|
3117db57c5 | ||
|
c2966d81e8 | ||
|
0d4bb295cc | ||
|
252e30cde0 | ||
|
d351a6225e | ||
|
b101d8f8a4 | ||
|
63b27f0292 | ||
|
a679327185 | ||
|
7035641c55 | ||
|
ec451acd1d | ||
|
cd2d6aeee9 | ||
|
78365d58c3 | ||
|
8503b3a9cc | ||
|
1516a0fd9a | ||
|
6aee3a755a | ||
|
2ca64a974c | ||
|
aa1faf0f6f | ||
|
1f2088453b | ||
|
58f2c0ce0f | ||
|
7fbffc4738 | ||
|
766ba2daa0 | ||
|
87a5148610 | ||
|
5552cd7ff1 | ||
|
51028d9efb | ||
|
cc7a5f9639 | ||
|
8cd7ff8993 | ||
|
bee1026738 | ||
|
0709628e83 | ||
|
f1972f56e5 | ||
|
20df7ebff6 | ||
|
656b727b33 | ||
|
ce1f59d3b5 | ||
|
57249bfe87 | ||
|
e451a214ff | ||
|
db3b23cd36 | ||
|
7c349b2431 | ||
|
cfba1ed39b | ||
|
ce7fa682d8 | ||
|
4669a4fc9b | ||
|
e58e74b5d4 | ||
|
d41ec2ae4d | ||
|
d1a4cacb8b | ||
|
fd3cf0c075 | ||
|
8053ed6c04 | ||
|
f2eae80c54 | ||
|
d53e443925 | ||
|
28673a807a | ||
|
85ea6de26c | ||
|
c106811e8e | ||
|
26db24d187 | ||
|
10ec9eff83 | ||
|
489e39033d | ||
|
3acf041087 | ||
|
7800b48193 | ||
|
250c8acfbd | ||
|
8c80a6aff3 | ||
|
3b4c4bf464 | ||
|
9abcf253a7 | ||
|
419273ecc6 | ||
|
49fe896b18 | ||
|
33c2cce6fd | ||
|
4556524f7e | ||
|
46d446718f | ||
|
f36a2d9bec | ||
|
34c4e6eaab | ||
|
0c967180f6 | ||
|
0b77809e9f | ||
|
94116b77a5 | ||
|
75688772b1 | ||
|
de60fd236f | ||
|
3096f3fe6a | ||
|
b1e94bd24c | ||
|
d35c337c28 | ||
|
445459afbb | ||
|
54f69e7e09 | ||
|
de956c8081 | ||
|
ebb3b81d48 | ||
|
177604e391 | ||
|
0a9394b244 | ||
|
2e45949e0c | ||
|
9da5bfcfb0 | ||
|
77d0523a5f | ||
|
344cc1b546 | ||
|
e8629a6be3 | ||
|
a0419de97c | ||
|
9d8dc37aad | ||
|
b2f17920c3 | ||
|
e8b4ba08a6 | ||
|
4a6b2a4de7 | ||
|
804f02c2de | ||
|
e085a8538e | ||
|
04b60282af | ||
|
5f7cde8be1 | ||
|
1b7bce579f | ||
|
42a2339978 | ||
|
d405ef9a52 | ||
|
291c8cd716 | ||
|
d5ac3aba8c | ||
|
6c30244b53 | ||
|
41d0dd3f30 | ||
|
ab6c2de227 | ||
|
39534edb6c | ||
|
de91f861f5 | ||
|
c71f0d8d98 | ||
|
ca47492b9b | ||
|
9097addc50 | ||
|
bfc47f4567 | ||
|
239e3a34fb | ||
|
3cc9a66990 | ||
|
2e67a4fb5e | ||
|
3446c180aa | ||
|
0b870c7db7 | ||
|
179711b930 | ||
|
3bb605d125 | ||
|
ded3c3d43a | ||
|
78c05a7e29 | ||
|
9e7d0a74f0 | ||
|
e177919630 | ||
|
65c6f804a3 | ||
|
cc35daedea | ||
|
ccb3bbc683 | ||
|
7892250dc2 | ||
|
5e8681c44e |
13
.github/ISSUE_TEMPLATE/bug_report_form.yml
vendored
13
.github/ISSUE_TEMPLATE/bug_report_form.yml
vendored
@ -8,12 +8,12 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Self service debugging
|
label: Self service debugging
|
||||||
description: |
|
description: |
|
||||||
Jellyfin 10.9 is actively updated. Please make sure you are using the [latest](https://github.com/jellyfin/jellyfin/releases/latest) release.
|
Jellyfin 10.9 is still being actively updated. Please make sure you are using the newest release
|
||||||
|
|
||||||
Many servers have permission issues that can be resolved with a few extra steps.
|
Docker containers have known permission issues that can be resolved with a few extra steps.
|
||||||
If your skip button is not shown, please see [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible) before reporting.
|
If your skip button is not shown, please see https://github.com/jumoog/intro-skipper/issues/104
|
||||||
options:
|
options:
|
||||||
- label: I use Jellyfin 10.10.3 (or newer) and my permissions are correct
|
- label: Jellyfin is updated and my permissions are correct (or I did not use Docker)
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
@ -44,7 +44,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Container image/tag or Jellyfin version
|
label: Container image/tag or Jellyfin version
|
||||||
description: The container for Docker or Jellyfin version for a native install
|
description: The container for Docker or Jellyfin version for a native install
|
||||||
placeholder: jellyfin/jellyfin:10.9.9, jellyfin-intro-skipper:latest, etc.
|
placeholder: jellyfin/jellyfin:10.8.7, jellyfin-intro-skipper:latest, etc.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@ -65,7 +65,6 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Support Bundle
|
label: Support Bundle
|
||||||
placeholder: go to Dashboard -> Plugins -> Intro Skipper -> Support Bundle (at the bottom of the page) and paste the contents of the textbox here
|
placeholder: go to Dashboard -> Plugins -> Intro Skipper -> Support Bundle (at the bottom of the page) and paste the contents of the textbox here
|
||||||
render: shell
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@ -73,4 +72,4 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Jellyfin logs
|
label: Jellyfin logs
|
||||||
placeholder: Paste any relevant logs here
|
placeholder: Paste any relevant logs here
|
||||||
render: shell
|
|
||||||
|
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@ -18,7 +18,7 @@ updates:
|
|||||||
- package-ecosystem: github-actions
|
- package-ecosystem: github-actions
|
||||||
directory: /
|
directory: /
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
interval: monthly
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
labels:
|
labels:
|
||||||
- ci
|
- ci
|
||||||
|
130
.github/workflows/build.yml
vendored
130
.github/workflows/build.yml
vendored
@ -1,19 +1,25 @@
|
|||||||
name: "Build Plugin"
|
name: 'Build Plugin'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [ "10.8" ]
|
||||||
- '*' # Triggers on any branch push
|
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "**/README.md"
|
- '**/README.md'
|
||||||
- ".github/ISSUE_TEMPLATE/**"
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
- "docs/**"
|
- 'docs/**'
|
||||||
- "images/**"
|
- 'images/**'
|
||||||
- "manifest.json"
|
- 'manifest.json'
|
||||||
|
pull_request:
|
||||||
|
branches: [ "10.8" ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**/README.md'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'images/**'
|
||||||
|
- 'manifest.json'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@ -22,63 +28,32 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Sanitize head_ref
|
|
||||||
run: |
|
|
||||||
# Get the branch name and sanitize it
|
|
||||||
SANITIZED_BRANCH_NAME=$(echo "${{ github.head_ref }}" | sed 's/[^a-zA-Z0-9.-]/_/g')
|
|
||||||
|
|
||||||
# Export it as an environment variable
|
|
||||||
echo "SANITIZED_BRANCH_NAME=$SANITIZED_BRANCH_NAME" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 6.0.x
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "lts/*"
|
node-version: 'lts/*'
|
||||||
|
|
||||||
|
- name: Install html-minifier-terser
|
||||||
|
run: npm install terser html-minifier-terser
|
||||||
|
|
||||||
- name: Minify HTML
|
- name: Minify HTML
|
||||||
run: |
|
run: |
|
||||||
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 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 IntroSkipper/Configuration/inject.js -o IntroSkipper/Configuration/inject.js -c -m
|
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m
|
||||||
npx terser IntroSkipper/Configuration/visualizer.js -o IntroSkipper/Configuration/visualizer.js -c -m
|
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.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
|
- name: Restore dependencies
|
||||||
if: ${{env.IS_BETA == 'false' }}
|
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Embed version info
|
- name: Embed version info
|
||||||
run: |
|
run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt
|
||||||
GITHUB_SHA=${{ github.sha }}
|
|
||||||
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" IntroSkipper/Helper/Commit.cs
|
|
||||||
|
|
||||||
- name: Retrieve commit identification
|
- name: Retrieve commit identification
|
||||||
run: |
|
run: |
|
||||||
@ -89,20 +64,59 @@ jobs:
|
|||||||
run: dotnet build --no-restore
|
run: dotnet build --no-restore
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4.3.6
|
uses: actions/upload-artifact@v4.3.3
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
with:
|
with:
|
||||||
name: IntroSkipper-${{ env.GIT_HASH }}.dll
|
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.GIT_HASH }}.dll
|
||||||
path: IntroSkipper/bin/Debug/net8.0/IntroSkipper.dll
|
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4.3.3
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
with:
|
||||||
|
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ github.head_ref }}.dll
|
||||||
|
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||||
|
retention-days: 7
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Create archive
|
- name: Create archive
|
||||||
if: startsWith(github.ref_name, '10.') # only do a preview release on 10.x branches
|
uses: vimtor/action-zip@v1.2
|
||||||
run: zip -j "intro-skipper-${{ env.GIT_HASH }}.zip" IntroSkipper/bin/Debug/net8.0/IntroSkipper.dll
|
if: github.event_name != 'pull_request'
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||||
|
dest: intro-skipper-${{ env.GIT_HASH }}.zip
|
||||||
|
|
||||||
- name: Create/replace the preview release and upload artifacts
|
- name: Generate md5
|
||||||
if: startsWith(github.ref_name, '10.') # only do a preview release on 10.x branches
|
if: github.event_name != 'pull_request'
|
||||||
run: |
|
run: |
|
||||||
gh release delete "${{ env.MAIN_VERSION }}/preview" --cleanup-tag --yes || true
|
md5sum intro-skipper-${{ env.GIT_HASH }}.zip > intro-skipper-${{ env.GIT_HASH }}.md5
|
||||||
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 }}
|
checksum="$(awk '{print $1}' intro-skipper-${{ env.GIT_HASH }}.md5)"
|
||||||
|
echo "CHECKSUM=$checksum" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Publish prerelease
|
||||||
|
uses: 8bitDream/action-github-releases@v1.0.0
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
with:
|
||||||
|
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
automatic_release_tag: 10.8/preview
|
||||||
|
prerelease: true
|
||||||
|
title: intro-skipper-${{ env.GIT_HASH }}
|
||||||
|
files: |
|
||||||
|
intro-skipper-${{ env.GIT_HASH }}.zip
|
||||||
|
|
||||||
|
- name: Publish prerelease notes
|
||||||
|
uses: softprops/action-gh-release@v2.0.5
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
with:
|
||||||
|
tag_name: 10.8/preview
|
||||||
|
name: intro-skipper-${{ env.GIT_HASH }}
|
||||||
|
append_body: true
|
||||||
|
body: |
|
||||||
|
---
|
||||||
|
checksum: ${{ env.CHECKSUM }}
|
||||||
|
draft: false
|
||||||
|
prerelease: true
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
53
.github/workflows/codeql.yml
vendored
53
.github/workflows/codeql.yml
vendored
@ -2,14 +2,21 @@ name: "CodeQL"
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [ 10.8 ]
|
||||||
- '*' # Triggers on any branch push
|
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "**/README.md"
|
- '**/README.md'
|
||||||
- ".github/ISSUE_TEMPLATE/**"
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
- "docs/**"
|
- 'docs/**'
|
||||||
- "images/**"
|
- 'images/**'
|
||||||
- "manifest.json"
|
- 'manifest.json'
|
||||||
|
pull_request:
|
||||||
|
branches: [ 10.8 ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**/README.md'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'images/**'
|
||||||
|
- 'manifest.json'
|
||||||
|
|
||||||
permissions: write-all
|
permissions: write-all
|
||||||
|
|
||||||
@ -18,50 +25,28 @@ jobs:
|
|||||||
name: Analyze
|
name: Analyze
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
# This job will only run if the repository is public
|
|
||||||
if: ${{ github.event.repository.private == false }}
|
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
language: ["csharp"]
|
language: [ 'csharp' ]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 6.0.x
|
||||||
|
|
||||||
- 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
|
- name: Restore dependencies
|
||||||
if: ${{env.IS_BETA == 'false' }}
|
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
uses: github/codeql-action/init@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended
|
queries: +security-extended
|
||||||
|
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
uses: github/codeql-action/autobuild@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
uses: github/codeql-action/analyze@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
|
||||||
|
108
.github/workflows/release.yml
vendored
108
.github/workflows/release.yml
vendored
@ -1,103 +1,109 @@
|
|||||||
name: "Release Plugin"
|
name: 'Release Plugin'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Read version from VERSION.txt
|
- name: Set up Node.js
|
||||||
id: read-version
|
uses: actions/setup-node@v4
|
||||||
run: |
|
with:
|
||||||
MAIN_VERSION=$(cat VERSION.txt)
|
node-version: '20'
|
||||||
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
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 6.0.x
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "lts/*"
|
node-version: 'lts/*'
|
||||||
|
|
||||||
|
- name: Install html-minifier-terser
|
||||||
|
run: npm install terser html-minifier-terser
|
||||||
|
|
||||||
- name: Minify HTML
|
- name: Minify HTML
|
||||||
run: |
|
run: |
|
||||||
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 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 IntroSkipper/Configuration/inject.js -o IntroSkipper/Configuration/inject.js -c -m
|
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m
|
||||||
npx terser IntroSkipper/Configuration/visualizer.js -o IntroSkipper/Configuration/visualizer.js -c -m
|
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.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
|
- name: Restore dependencies
|
||||||
if: ${{env.IS_BETA == 'false' }}
|
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Run update version
|
- name: Run update version
|
||||||
uses: intro-skipper/intro-skipper-action-ts@main
|
run: node update-version.js
|
||||||
with:
|
|
||||||
task-type: "updateVersion"
|
|
||||||
|
|
||||||
- name: Embed version info
|
- name: Embed version info
|
||||||
run: |
|
run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt
|
||||||
GITHUB_SHA=${{ github.sha }}
|
|
||||||
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" IntroSkipper/Helper/Commit.cs
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build --configuration Release --no-restore
|
run: dotnet build --configuration Release --no-restore
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4.3.3
|
||||||
|
with:
|
||||||
|
name: ConfusedPolarBear.Plugin.IntroSkipper-v${{ env.NEW_FILE_VERSION }}.dll
|
||||||
|
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Release/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Create archive
|
- name: Create archive
|
||||||
run: zip -j "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" IntroSkipper/bin/Release/net8.0/IntroSkipper.dll
|
uses: vimtor/action-zip@v1.2
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||||
|
dest: intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip
|
||||||
|
|
||||||
- name: Remove old release if exits
|
- name: Generate manifest keys
|
||||||
if: ${{ github.repository == 'intro-skipper/intro-skipper-test' }}
|
run: |
|
||||||
run: gh release delete "${{ env.MAIN_VERSION }}/v${{ env.NEW_FILE_VERSION }}" --cleanup-tag --yes || true
|
sourceUrl="https://github.com/${{ github.repository }}/releases/download/10.8/v${{ env.NEW_FILE_VERSION }}/intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip"
|
||||||
env:
|
echo "SOURCE_URL=$sourceUrl" >> $GITHUB_ENV
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
md5sum intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip > intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5
|
||||||
|
checksum="$(awk '{print $1}' intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5)"
|
||||||
|
echo "CHECKSUM=$checksum" >> $GITHUB_ENV
|
||||||
|
timestamp="$(date +%FT%TZ)"
|
||||||
|
echo "TIMESTAMP=$timestamp" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Create new release with tag
|
- name: Publish release
|
||||||
if: github.event_name != 'pull_request'
|
uses: 8bitDream/action-github-releases@v1.0.0
|
||||||
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 }}
|
with:
|
||||||
|
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
automatic_release_tag: 10.8/v${{ env.NEW_FILE_VERSION }}
|
||||||
|
prerelease: false
|
||||||
|
title: v${{ env.NEW_FILE_VERSION }}
|
||||||
|
files: |
|
||||||
|
intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip
|
||||||
|
|
||||||
|
- name: Publish release notes
|
||||||
|
uses: softprops/action-gh-release@v2.0.5
|
||||||
|
with:
|
||||||
|
tag_name: 10.8/v${{ env.NEW_FILE_VERSION }}
|
||||||
|
name: v${{ env.NEW_FILE_VERSION }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Run validation and update script
|
- name: Run validation and update script
|
||||||
uses: intro-skipper/intro-skipper-action-ts@main
|
run: node validate-and-update-manifest.js
|
||||||
with:
|
|
||||||
task-type: "updateManifest"
|
|
||||||
env:
|
env:
|
||||||
GITHUB_REPO_VISIBILITY: ${{ github.event.repository.visibility }}
|
VERSION: ${{ env.NEW_FILE_VERSION }}
|
||||||
MAIN_VERSION: ${{ env.MAIN_VERSION }}
|
|
||||||
|
|
||||||
- name: Commit changes
|
- name: Commit changes
|
||||||
if: success()
|
if: success()
|
||||||
run: |
|
run: |
|
||||||
git config --global user.name "github-actions[bot]"
|
git config --global user.name "github-actions[bot]"
|
||||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add README.md manifest.json IntroSkipper/IntroSkipper.csproj .github/ISSUE_TEMPLATE/bug_report_form.yml
|
git add manifest.json ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj
|
||||||
git commit -m "release v${{ env.NEW_FILE_VERSION }}"
|
git commit -m "release v${{ env.NEW_FILE_VERSION }}"
|
||||||
git push
|
git push
|
||||||
|
41
.github/workflows/webui.yml
vendored
41
.github/workflows/webui.yml
vendored
@ -1,41 +0,0 @@
|
|||||||
name: Create Jellyfin-web artifact
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
jellyfin-web-version: [10.9.11]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Setup Node.js environment
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ">=20"
|
|
||||||
- name: Checkout official jellyfin-web
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: jellyfin/jellyfin-web
|
|
||||||
ref: v${{ matrix.jellyfin-web-version }}
|
|
||||||
path: web
|
|
||||||
- name: Apply intro skipper patch
|
|
||||||
run: |
|
|
||||||
cd web
|
|
||||||
git apply ../webui.patch
|
|
||||||
- name: Build web interface
|
|
||||||
run: |
|
|
||||||
cd web
|
|
||||||
npm ci --no-audit
|
|
||||||
npm run build:production
|
|
||||||
- name: Upload web interface
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: jellyfin-web-${{ matrix.jellyfin-web-version }}+${{ github.sha }}
|
|
||||||
path: web/dist
|
|
||||||
if-no-files-found: error
|
|
11
.vscode/extensions.json
vendored
11
.vscode/extensions.json
vendored
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"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
9
.vscode/settings.json
vendored
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"editor.insertSpaces": true,
|
|
||||||
"editor.tabSize": 4,
|
|
||||||
"files.encoding": "utf8",
|
|
||||||
"files.trimTrailingWhitespace": true,
|
|
||||||
"files.insertFinalNewline": true,
|
|
||||||
"editor.rulers": [],
|
|
||||||
"dotnet.defaultSolution": "IntroSkipper.sln"
|
|
||||||
}
|
|
@ -4,4 +4,3 @@ Intro Skipper is made possible by the following open source projects:
|
|||||||
* [chromaprint](https://github.com/acoustid/chromaprint) (LGPL 2.1)
|
* [chromaprint](https://github.com/acoustid/chromaprint) (LGPL 2.1)
|
||||||
* [JellyScrub](https://github.com/nicknsy/jellyscrub) (MIT)
|
* [JellyScrub](https://github.com/nicknsy/jellyscrub) (MIT)
|
||||||
* [Jellyfin](https://github.com/jellyfin/jellyfin) (GPL)
|
* [Jellyfin](https://github.com/jellyfin/jellyfin) (GPL)
|
||||||
* [Jellyfin Media Analyzer](https://github.com/endrl/jellyfin-plugin-media-analyzer) (GPL)
|
|
||||||
|
93
CHANGELOG.md
Normal file
93
CHANGELOG.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v0.1.8.0 (no eta)
|
||||||
|
* New features
|
||||||
|
* Support adding skip intro button to web interface without using a fork
|
||||||
|
* Add localization support for the skip intro button and the automatic skip notification message
|
||||||
|
* Detect ending credits in television episodes
|
||||||
|
* Add support for using chapter names to locate introductions and ending credits
|
||||||
|
* Add support for using black frames to locate ending credits
|
||||||
|
* Show skip button when on screen controls are visible (#149 by @DualScorch)
|
||||||
|
* Internal changes
|
||||||
|
* Move Chromaprint analysis code out of the episode analysis task
|
||||||
|
* Add support for multiple analysis techinques
|
||||||
|
|
||||||
|
## v0.1.7.0 (2022-10-26)
|
||||||
|
* New features
|
||||||
|
* Rewrote fingerprint comparison algorithm to be faster (~30x speedup) and detect more introductions
|
||||||
|
* Detect silence at the end of introductions and use it to avoid skipping over the beginning of an episode
|
||||||
|
* If you are upgrading from a previous release and want to use the silence detection feature on shows that have already been analyzed, you must click the `Erase introduction timestamps` button at the bottom of the plugin settings page
|
||||||
|
* Add support bundle
|
||||||
|
* Add maximum introduction duration
|
||||||
|
* Support playing a few seconds from the end of the introduction to verify that no episode content was skipped over
|
||||||
|
* Amount played is customizable and defaults to 2 seconds
|
||||||
|
* Support modifying introduction detection algorithm settings
|
||||||
|
* Add option to not skip the introduction in the first episode of a season
|
||||||
|
* Add option to analyze show extras (specials)
|
||||||
|
* Fixes
|
||||||
|
* Fix scheduled task interval (#79)
|
||||||
|
* Prevent show names from becoming duplicated in the show name dropdown under the advanced section
|
||||||
|
* Prevent virtual episodes from being inserted into the analysis queue
|
||||||
|
|
||||||
|
## v0.1.6.0 (2022-08-04)
|
||||||
|
* New features
|
||||||
|
* Generate EDL files with intro timestamps ([documentation](docs/edl.md)) (#21)
|
||||||
|
* Support selecting which libraries are analyzed (#37)
|
||||||
|
* Support customizing [introduction requirements](README.md#introduction-requirements) (#38, #51)
|
||||||
|
* Changing these settings will increase episode analysis times
|
||||||
|
* Support adding and editing intro timestamps (#26)
|
||||||
|
* Report how CPU time is being spent while analyzing episodes
|
||||||
|
* CPU time reports can be viewed under "Analysis Statistics (experimental)" in the plugin configuration page
|
||||||
|
* Sped up fingerprint analysis (not including fingerprint generation time) by 40%
|
||||||
|
* Support erasing discovered introductions by season
|
||||||
|
* Suggest potential shifts in the fingerprint visualizer
|
||||||
|
|
||||||
|
* Fixes
|
||||||
|
* Ensure episode analysis queue matches the current filesystem and library state (#42, #60)
|
||||||
|
* Fixes a bug where renamed or deleted episodes were being analyzed
|
||||||
|
* Fix automatic intro skipping on Android TV (#57, #61)
|
||||||
|
* Restore per season status updates in the log
|
||||||
|
* Prevent null key in `/Intros/Shows` endpoint (#27)
|
||||||
|
* Fix positioning of skip intro button on mobile devices (#43)
|
||||||
|
* Ensure video playback always resumes after clicking the skip intro button (#44)
|
||||||
|
|
||||||
|
## v0.1.5.0 (2022-06-17)
|
||||||
|
* Use `ffmpeg` to generate audio fingerprints instead of `fpcalc`
|
||||||
|
* Requires that the installed version of `ffmpeg`:
|
||||||
|
* Was compiled with the `--enable-chromaprint` option
|
||||||
|
* Understands the `-fp_format raw` flag
|
||||||
|
* `jellyfin-ffmpeg 5.0.1-5` meets both of these requirements
|
||||||
|
* Version API endpoints
|
||||||
|
* See [api.md](docs/api.md) for detailed documentation on how clients can work with this plugin
|
||||||
|
* Add commit hash to unstable builds
|
||||||
|
* Log media paths that are unable to be fingerprinted
|
||||||
|
* Report failure to the UI if the episode analysis queue is empty
|
||||||
|
* Allow customizing degrees of parallelism
|
||||||
|
* Warning: Using a value that is too high will result in system instability
|
||||||
|
* Remove restart requirement to change auto skip setting
|
||||||
|
* Rewrite startup enqueue
|
||||||
|
* Fix deadlock issue on Windows (#23 by @nyanmisaka)
|
||||||
|
* Improve skip intro button styling & positioning (ConfusedPolarBear/jellyfin-web#91 by @Fallenbagel)
|
||||||
|
* Order episodes by `IndexNumber` (#25 reported by @Flo56958)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.1.0.0 (2022-06-09)
|
||||||
|
* Add option to automatically skip intros
|
||||||
|
* Cache audio fingerprints by default
|
||||||
|
* Add fingerprint visualizer
|
||||||
|
* Add button to erase all previously discovered intro timestamps
|
||||||
|
* Made saving settings more reliable
|
||||||
|
* Switch to new fingerprint comparison algorithm
|
||||||
|
* If you would like to test the new comparison algorithm, you will have to erase all previously discovered introduction timestamps.
|
||||||
|
|
||||||
|
## v0.0.0.3 (2022-05-21)
|
||||||
|
* Fix `fpcalc` version check
|
||||||
|
|
||||||
|
## v0.0.0.2 (2022-05-21)
|
||||||
|
* Analyze multiple seasons in parallel
|
||||||
|
* Reanalyze episodes with an unusually short or long intro sequence
|
||||||
|
* Check installed `fpcalc` version
|
||||||
|
* Clarify installation instructions
|
||||||
|
|
||||||
|
## v0.0.0.1 (2022-05-10)
|
||||||
|
* First alpha build
|
@ -1,16 +1,16 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.7.0" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
@ -21,7 +21,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\IntroSkipper\IntroSkipper.csproj" />
|
<ProjectReference Include="..\ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
@ -7,12 +7,10 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using IntroSkipper.Analyzers;
|
|
||||||
using IntroSkipper.Data;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace IntroSkipper.Tests;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||||
|
|
||||||
public class TestAudioFingerprinting
|
public class TestAudioFingerprinting
|
||||||
{
|
{
|
||||||
@ -31,7 +29,8 @@ public class TestAudioFingerprinting
|
|||||||
[InlineData(19, 2_465_585_877)]
|
[InlineData(19, 2_465_585_877)]
|
||||||
public void TestBitCounting(int expectedBits, uint number)
|
public void TestBitCounting(int expectedBits, uint number)
|
||||||
{
|
{
|
||||||
Assert.Equal(expectedBits, ChromaprintAnalyzer.CountBits(number));
|
var chromaprint = CreateChromaprintAnalyzer();
|
||||||
|
Assert.Equal(expectedBits, chromaprint.CountBits(number));
|
||||||
}
|
}
|
||||||
|
|
||||||
[FactSkipFFmpegTests]
|
[FactSkipFFmpegTests]
|
||||||
@ -64,7 +63,7 @@ public class TestAudioFingerprinting
|
|||||||
};
|
};
|
||||||
|
|
||||||
var actual = FFmpegWrapper.Fingerprint(
|
var actual = FFmpegWrapper.Fingerprint(
|
||||||
QueueEpisode("audio/big_buck_bunny_intro.mp3"),
|
queueEpisode("audio/big_buck_bunny_intro.mp3"),
|
||||||
AnalysisMode.Introduction);
|
AnalysisMode.Introduction);
|
||||||
|
|
||||||
Assert.Equal(expected, actual);
|
Assert.Equal(expected, actual);
|
||||||
@ -85,8 +84,7 @@ public class TestAudioFingerprinting
|
|||||||
{77, 5},
|
{77, 5},
|
||||||
};
|
};
|
||||||
|
|
||||||
var analyzer = CreateChromaprintAnalyzer();
|
var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction);
|
||||||
var actual = analyzer.CreateInvertedIndex(Guid.NewGuid(), fpr);
|
|
||||||
|
|
||||||
Assert.Equal(expected, actual);
|
Assert.Equal(expected, actual);
|
||||||
}
|
}
|
||||||
@ -96,8 +94,8 @@ public class TestAudioFingerprinting
|
|||||||
{
|
{
|
||||||
var chromaprint = CreateChromaprintAnalyzer();
|
var chromaprint = CreateChromaprintAnalyzer();
|
||||||
|
|
||||||
var lhsEpisode = QueueEpisode("audio/big_buck_bunny_intro.mp3");
|
var lhsEpisode = queueEpisode("audio/big_buck_bunny_intro.mp3");
|
||||||
var rhsEpisode = QueueEpisode("audio/big_buck_bunny_clip.mp3");
|
var rhsEpisode = queueEpisode("audio/big_buck_bunny_clip.mp3");
|
||||||
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction);
|
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction);
|
||||||
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction);
|
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction);
|
||||||
|
|
||||||
@ -108,13 +106,13 @@ public class TestAudioFingerprinting
|
|||||||
rhsFingerprint);
|
rhsFingerprint);
|
||||||
|
|
||||||
Assert.True(lhs.Valid);
|
Assert.True(lhs.Valid);
|
||||||
Assert.Equal(0, lhs.Start);
|
Assert.Equal(0, lhs.IntroStart);
|
||||||
Assert.Equal(17.208, lhs.End, 3);
|
Assert.Equal(17.208, lhs.IntroEnd, 3);
|
||||||
|
|
||||||
Assert.True(rhs.Valid);
|
Assert.True(rhs.Valid);
|
||||||
// because we changed for 0.128 to 0.1238 its 4,952 now but that's too early (<= 5)
|
// because we changed for 0.128 to 0.1238 its 4,952 now but that's too early (<= 5)
|
||||||
Assert.Equal(0, rhs.Start);
|
Assert.Equal(0, rhs.IntroStart);
|
||||||
Assert.Equal(22.1602, rhs.End);
|
Assert.Equal(22.1602, rhs.IntroEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -123,25 +121,24 @@ public class TestAudioFingerprinting
|
|||||||
[FactSkipFFmpegTests]
|
[FactSkipFFmpegTests]
|
||||||
public void TestSilenceDetection()
|
public void TestSilenceDetection()
|
||||||
{
|
{
|
||||||
var clip = QueueEpisode("audio/big_buck_bunny_clip.mp3");
|
var clip = queueEpisode("audio/big_buck_bunny_clip.mp3");
|
||||||
|
|
||||||
var expected = new TimeRange[]
|
var expected = new TimeRange[]
|
||||||
{
|
{
|
||||||
new(44.631042, 44.807167),
|
new TimeRange(44.6310, 44.8072),
|
||||||
new(53.590521, 53.806979),
|
new TimeRange(53.5905, 53.8070),
|
||||||
new(53.845833, 54.202417),
|
new TimeRange(53.8458, 54.2024),
|
||||||
new(54.261104, 54.593479),
|
new TimeRange(54.2611, 54.5935),
|
||||||
new(54.709792, 54.929312),
|
new TimeRange(54.7098, 54.9293),
|
||||||
new(54.929396, 55.258979),
|
new TimeRange(54.9294, 55.2590),
|
||||||
};
|
};
|
||||||
|
|
||||||
var range = new TimeRange(0, 60);
|
var actual = FFmpegWrapper.DetectSilence(clip, 60);
|
||||||
var actual = FFmpegWrapper.DetectSilence(clip, range);
|
|
||||||
|
|
||||||
Assert.Equal(expected, actual);
|
Assert.Equal(expected, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static QueuedEpisode QueueEpisode(string path)
|
private QueuedEpisode queueEpisode(string path)
|
||||||
{
|
{
|
||||||
return new QueuedEpisode()
|
return new QueuedEpisode()
|
||||||
{
|
{
|
||||||
@ -151,7 +148,7 @@ public class TestAudioFingerprinting
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChromaprintAnalyzer CreateChromaprintAnalyzer()
|
private ChromaprintAnalyzer CreateChromaprintAnalyzer()
|
||||||
{
|
{
|
||||||
var logger = new LoggerFactory().CreateLogger<ChromaprintAnalyzer>();
|
var logger = new LoggerFactory().CreateLogger<ChromaprintAnalyzer>();
|
||||||
return new(logger);
|
return new(logger);
|
@ -1,12 +1,10 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace IntroSkipper.Tests;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using IntroSkipper.Analyzers;
|
|
||||||
using IntroSkipper.Data;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@ -18,11 +16,11 @@ public class TestBlackFrames
|
|||||||
var range = 1e-5;
|
var range = 1e-5;
|
||||||
|
|
||||||
var expected = new List<BlackFrame>();
|
var expected = new List<BlackFrame>();
|
||||||
expected.AddRange(CreateFrameSequence(2, 3));
|
expected.AddRange(CreateFrameSequence(2.04, 3));
|
||||||
expected.AddRange(CreateFrameSequence(5, 6));
|
expected.AddRange(CreateFrameSequence(5, 6));
|
||||||
expected.AddRange(CreateFrameSequence(8, 9.96));
|
expected.AddRange(CreateFrameSequence(8, 9.96));
|
||||||
|
|
||||||
var actual = FFmpegWrapper.DetectBlackFrames(QueueFile("rainbow.mp4"), new(0, 10), 85);
|
var actual = FFmpegWrapper.DetectBlackFrames(queueFile("rainbow.mp4"), new(0, 10), 85);
|
||||||
|
|
||||||
for (var i = 0; i < expected.Count; i++)
|
for (var i = 0; i < expected.Count; i++)
|
||||||
{
|
{
|
||||||
@ -40,15 +38,15 @@ public class TestBlackFrames
|
|||||||
|
|
||||||
var analyzer = CreateBlackFrameAnalyzer();
|
var analyzer = CreateBlackFrameAnalyzer();
|
||||||
|
|
||||||
var episode = QueueFile("credits.mp4");
|
var episode = queueFile("credits.mp4");
|
||||||
episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds;
|
episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds;
|
||||||
|
|
||||||
var result = analyzer.AnalyzeMediaFile(episode, 240, 85);
|
var result = analyzer.AnalyzeMediaFile(episode, 240, 30, 85);
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.InRange(result.Start, 300 - range, 300 + range);
|
Assert.InRange(result.IntroStart, 300 - range, 300 + range);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static QueuedEpisode QueueFile(string path)
|
private QueuedEpisode queueFile(string path)
|
||||||
{
|
{
|
||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
@ -58,7 +56,7 @@ public class TestBlackFrames
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static BlackFrame[] CreateFrameSequence(double start, double end)
|
private BlackFrame[] CreateFrameSequence(double start, double end)
|
||||||
{
|
{
|
||||||
var frames = new List<BlackFrame>();
|
var frames = new List<BlackFrame>();
|
||||||
|
|
||||||
@ -67,10 +65,10 @@ public class TestBlackFrames
|
|||||||
frames.Add(new(100, i));
|
frames.Add(new(100, i));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. frames];
|
return frames.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static BlackFrameAnalyzer CreateBlackFrameAnalyzer()
|
private BlackFrameAnalyzer CreateBlackFrameAnalyzer()
|
||||||
{
|
{
|
||||||
var logger = new LoggerFactory().CreateLogger<BlackFrameAnalyzer>();
|
var logger = new LoggerFactory().CreateLogger<BlackFrameAnalyzer>();
|
||||||
return new(logger);
|
return new(logger);
|
@ -1,13 +1,11 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace IntroSkipper.Tests;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using IntroSkipper.Analyzers;
|
|
||||||
using IntroSkipper.Data;
|
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@ -26,8 +24,8 @@ public class TestChapterAnalyzer
|
|||||||
var introChapter = FindChapter(chapters, AnalysisMode.Introduction);
|
var introChapter = FindChapter(chapters, AnalysisMode.Introduction);
|
||||||
|
|
||||||
Assert.NotNull(introChapter);
|
Assert.NotNull(introChapter);
|
||||||
Assert.Equal(60, introChapter.Start);
|
Assert.Equal(60, introChapter.IntroStart);
|
||||||
Assert.Equal(90, introChapter.End);
|
Assert.Equal(90, introChapter.IntroEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@ -42,11 +40,11 @@ public class TestChapterAnalyzer
|
|||||||
var creditsChapter = FindChapter(chapters, AnalysisMode.Credits);
|
var creditsChapter = FindChapter(chapters, AnalysisMode.Credits);
|
||||||
|
|
||||||
Assert.NotNull(creditsChapter);
|
Assert.NotNull(creditsChapter);
|
||||||
Assert.Equal(1890, creditsChapter.Start);
|
Assert.Equal(1890, creditsChapter.IntroStart);
|
||||||
Assert.Equal(2000, creditsChapter.End);
|
Assert.Equal(2000, creditsChapter.IntroEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Segment? FindChapter(Collection<ChapterInfo> chapters, AnalysisMode mode)
|
private Intro? FindChapter(Collection<ChapterInfo> chapters, AnalysisMode mode)
|
||||||
{
|
{
|
||||||
var logger = new LoggerFactory().CreateLogger<ChapterAnalyzer>();
|
var logger = new LoggerFactory().CreateLogger<ChapterAnalyzer>();
|
||||||
var analyzer = new ChapterAnalyzer(logger);
|
var analyzer = new ChapterAnalyzer(logger);
|
||||||
@ -77,7 +75,7 @@ public class TestChapterAnalyzer
|
|||||||
/// <param name="name">Chapter name.</param>
|
/// <param name="name">Chapter name.</param>
|
||||||
/// <param name="position">Chapter position (in seconds).</param>
|
/// <param name="position">Chapter position (in seconds).</param>
|
||||||
/// <returns>ChapterInfo.</returns>
|
/// <returns>ChapterInfo.</returns>
|
||||||
private static ChapterInfo CreateChapter(string name, int position)
|
private ChapterInfo CreateChapter(string name, int position)
|
||||||
{
|
{
|
||||||
return new()
|
return new()
|
||||||
{
|
{
|
@ -1,10 +1,9 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using IntroSkipper.Data;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace IntroSkipper.Tests;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||||
|
|
||||||
public class TestTimeRanges
|
public class TestTimeRanges
|
||||||
{
|
{
|
47
ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs
Normal file
47
ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
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 Intro MakeIntro(double start, double end)
|
||||||
|
{
|
||||||
|
return new Intro(Guid.Empty, new TimeRange(start, end));
|
||||||
|
}
|
||||||
|
}
|
37
ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestWarnings.cs
Normal file
37
ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestWarnings.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||||
|
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public class TestFlags
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TestEmptyFlagSerialization()
|
||||||
|
{
|
||||||
|
WarningManager.Clear();
|
||||||
|
Assert.Equal("None", WarningManager.GetWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TestSingleFlagSerialization()
|
||||||
|
{
|
||||||
|
WarningManager.Clear();
|
||||||
|
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
|
||||||
|
Assert.Equal("UnableToAddSkipButton", WarningManager.GetWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TestDoubleFlagSerialization()
|
||||||
|
{
|
||||||
|
WarningManager.Clear();
|
||||||
|
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
|
||||||
|
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
|
||||||
|
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"UnableToAddSkipButton, InvalidChromaprintFingerprint",
|
||||||
|
WarningManager.GetWarnings());
|
||||||
|
}
|
||||||
|
}
|
@ -39,6 +39,7 @@ Selenium is used to verify that the plugin's web interface works as expected. It
|
|||||||
* Changing settings (will be added in the future)
|
* Changing settings (will be added in the future)
|
||||||
* Maximum degree of parallelism
|
* Maximum degree of parallelism
|
||||||
* Selecting libraries for analysis
|
* Selecting libraries for analysis
|
||||||
|
* EDL settings
|
||||||
* Introduction requirements
|
* Introduction requirements
|
||||||
* Auto skip
|
* Auto skip
|
||||||
* Show/hide skip prompt
|
* Show/hide skip prompt
|
0
IntroSkipper.Tests/e2e_tests/build.sh → ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/build.sh
Normal file → Executable file
0
IntroSkipper.Tests/e2e_tests/build.sh → ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/build.sh
Normal file → Executable file
@ -1,8 +1,8 @@
|
|||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
#
|
#
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntroSkipper", "IntroSkipper\IntroSkipper.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper", "ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntroSkipper.Tests", "IntroSkipper.Tests\IntroSkipper.Tests.csproj", "{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper.Tests", "ConfusedPolarBear.Plugin.IntroSkipper.Tests\ConfusedPolarBear.Plugin.IntroSkipper.Tests.csproj", "{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
@ -0,0 +1,206 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <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 int minimumCreditsDuration;
|
||||||
|
|
||||||
|
private int maximumCreditsDuration;
|
||||||
|
|
||||||
|
private 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 Configuration.PluginConfiguration();
|
||||||
|
minimumCreditsDuration = config.MinimumCreditsDuration;
|
||||||
|
maximumCreditsDuration = 2 * config.MaximumCreditsDuration;
|
||||||
|
blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage;
|
||||||
|
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
|
||||||
|
ReadOnlyCollection<QueuedEpisode> analysisQueue,
|
||||||
|
AnalysisMode mode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (mode != AnalysisMode.Credits)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException("mode must equal Credits");
|
||||||
|
}
|
||||||
|
|
||||||
|
var creditTimes = new Dictionary<Guid, Intro>();
|
||||||
|
|
||||||
|
bool isFirstEpisode = true;
|
||||||
|
|
||||||
|
double searchStart = minimumCreditsDuration;
|
||||||
|
|
||||||
|
var searchDistance = 2 * minimumCreditsDuration;
|
||||||
|
|
||||||
|
foreach (var episode in analysisQueue)
|
||||||
|
{
|
||||||
|
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 intro = AnalyzeMediaFile(
|
||||||
|
episode,
|
||||||
|
searchStart,
|
||||||
|
searchDistance,
|
||||||
|
blackFrameMinimumPercentage);
|
||||||
|
|
||||||
|
if (intro 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 - intro.IntroStart + (0.5 * searchDistance);
|
||||||
|
|
||||||
|
creditTimes[episode.EpisodeId] = intro;
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugin.Instance!.UpdateTimestamps(creditTimes, mode);
|
||||||
|
|
||||||
|
return analysisQueue
|
||||||
|
.Where(x => !creditTimes.ContainsKey(x.EpisodeId))
|
||||||
|
.ToList()
|
||||||
|
.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 Intro? 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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,249 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chapter name analyzer.
|
||||||
|
/// </summary>
|
||||||
|
public class ChapterAnalyzer : IMediaFileAnalyzer
|
||||||
|
{
|
||||||
|
private ILogger<ChapterAnalyzer> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ChapterAnalyzer"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public ChapterAnalyzer(ILogger<ChapterAnalyzer> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
|
||||||
|
ReadOnlyCollection<QueuedEpisode> analysisQueue,
|
||||||
|
AnalysisMode mode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var skippableRanges = new Dictionary<Guid, Intro>();
|
||||||
|
|
||||||
|
var expression = mode == AnalysisMode.Introduction ?
|
||||||
|
Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern :
|
||||||
|
Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(expression))
|
||||||
|
{
|
||||||
|
return analysisQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var episode in analysisQueue)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var skipRange = FindMatchingChapter(
|
||||||
|
episode,
|
||||||
|
new(Plugin.Instance!.GetChapters(episode.EpisodeId)),
|
||||||
|
expression,
|
||||||
|
mode);
|
||||||
|
|
||||||
|
if (skipRange is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
skippableRanges.Add(episode.EpisodeId, skipRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugin.Instance!.UpdateTimestamps(skippableRanges, mode);
|
||||||
|
|
||||||
|
return analysisQueue
|
||||||
|
.Where(x => !skippableRanges.ContainsKey(x.EpisodeId))
|
||||||
|
.ToList()
|
||||||
|
.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches a list of chapter names for one that matches the provided regular expression.
|
||||||
|
/// Only public to allow for unit testing.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="episode">Episode.</param>
|
||||||
|
/// <param name="chapters">Media item chapters.</param>
|
||||||
|
/// <param name="expression">Regular expression pattern.</param>
|
||||||
|
/// <param name="mode">Analysis mode.</param>
|
||||||
|
/// <returns>Intro object containing skippable time range, or null if no chapter matched.</returns>
|
||||||
|
public Intro? FindMatchingChapter(
|
||||||
|
QueuedEpisode episode,
|
||||||
|
Collection<ChapterInfo> chapters,
|
||||||
|
string expression,
|
||||||
|
AnalysisMode mode)
|
||||||
|
{
|
||||||
|
Intro? matchingChapter = null;
|
||||||
|
|
||||||
|
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
|
||||||
|
|
||||||
|
var minDuration = mode == AnalysisMode.Introduction ?
|
||||||
|
config.MinimumIntroDuration :
|
||||||
|
config.MinimumCreditsDuration;
|
||||||
|
int maxDuration = mode == AnalysisMode.Introduction ?
|
||||||
|
config.MaximumIntroDuration :
|
||||||
|
config.MaximumCreditsDuration;
|
||||||
|
|
||||||
|
if (chapters.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == AnalysisMode.Credits)
|
||||||
|
{
|
||||||
|
// Since the ending credits chapter may be the last chapter in the file, append a virtual
|
||||||
|
// chapter at the very end of the file.
|
||||||
|
chapters.Add(new()
|
||||||
|
{
|
||||||
|
StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check all chapters in reverse order, skipping the virtual chapter
|
||||||
|
for (int i = chapters.Count - 2; i > 0; i--)
|
||||||
|
{
|
||||||
|
var current = chapters[i];
|
||||||
|
var previous = chapters[i - 1];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(current.Name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentRange = new TimeRange(
|
||||||
|
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
|
||||||
|
TimeSpan.FromTicks(chapters[i + 1].StartPositionTicks).TotalSeconds);
|
||||||
|
|
||||||
|
var baseMessage = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}: Chapter \"{1}\" ({2} - {3})",
|
||||||
|
episode.Path,
|
||||||
|
current.Name,
|
||||||
|
currentRange.Start,
|
||||||
|
currentRange.End);
|
||||||
|
|
||||||
|
if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex
|
||||||
|
// between function invocations.
|
||||||
|
var match = Regex.IsMatch(
|
||||||
|
current.Name,
|
||||||
|
expression,
|
||||||
|
RegexOptions.None,
|
||||||
|
TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
if (!match)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(previous.Name))
|
||||||
|
{
|
||||||
|
// Check for possibility of overlapping keywords
|
||||||
|
var overlap = Regex.IsMatch(
|
||||||
|
previous.Name,
|
||||||
|
expression,
|
||||||
|
RegexOptions.None,
|
||||||
|
TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
if (overlap)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingChapter = new(episode.EpisodeId, currentRange);
|
||||||
|
_logger.LogTrace("{Base}: okay", baseMessage);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Check all chapters
|
||||||
|
for (int i = 0; i < chapters.Count - 1; i++)
|
||||||
|
{
|
||||||
|
var current = chapters[i];
|
||||||
|
var next = chapters[i + 1];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(current.Name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentRange = new TimeRange(
|
||||||
|
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
|
||||||
|
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
|
||||||
|
|
||||||
|
var baseMessage = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}: Chapter \"{1}\" ({2} - {3})",
|
||||||
|
episode.Path,
|
||||||
|
current.Name,
|
||||||
|
currentRange.Start,
|
||||||
|
currentRange.End);
|
||||||
|
|
||||||
|
if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex
|
||||||
|
// between function invocations.
|
||||||
|
var match = Regex.IsMatch(
|
||||||
|
current.Name,
|
||||||
|
expression,
|
||||||
|
RegexOptions.None,
|
||||||
|
TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
if (!match)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(next.Name))
|
||||||
|
{
|
||||||
|
// Check for possibility of overlapping keywords
|
||||||
|
var overlap = Regex.IsMatch(
|
||||||
|
next.Name,
|
||||||
|
expression,
|
||||||
|
RegexOptions.None,
|
||||||
|
TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
if (overlap)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingChapter = new(episode.EpisodeId, currentRange);
|
||||||
|
_logger.LogTrace("{Base}: okay", baseMessage);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchingChapter;
|
||||||
|
}
|
||||||
|
}
|
@ -1,56 +1,76 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Collections.ObjectModel;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IntroSkipper.Configuration;
|
|
||||||
using IntroSkipper.Data;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace IntroSkipper.Analyzers;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
|
/// Chromaprint audio analyzer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="logger">Logger.</param>
|
public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||||
public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFileAnalyzer
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seconds of audio in one fingerprint point.
|
/// Seconds of audio in one fingerprint point.
|
||||||
/// This value is defined by the Chromaprint library and should not be changed.
|
/// This value is defined by the Chromaprint library and should not be changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const double SamplesToSeconds = 0.1238;
|
private const double SamplesToSeconds = 0.1238;
|
||||||
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
|
||||||
private readonly ILogger<ChromaprintAnalyzer> _logger = logger;
|
private int minimumIntroDuration;
|
||||||
private readonly Dictionary<Guid, Dictionary<uint, int>> _invertedIndexCache = [];
|
|
||||||
|
private int maximumDifferences;
|
||||||
|
|
||||||
|
private int invertedIndexShift;
|
||||||
|
|
||||||
|
private double maximumTimeSkip;
|
||||||
|
|
||||||
|
private double silenceDetectionMinimumDuration;
|
||||||
|
|
||||||
|
private ILogger<ChromaprintAnalyzer> _logger;
|
||||||
|
|
||||||
private AnalysisMode _analysisMode;
|
private AnalysisMode _analysisMode;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger)
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
|
||||||
|
maximumDifferences = config.MaximumFingerprintPointDifferences;
|
||||||
|
invertedIndexShift = config.InvertedIndexShift;
|
||||||
|
maximumTimeSkip = config.MaximumTimeSkip;
|
||||||
|
silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration;
|
||||||
|
minimumIntroDuration = config.MinimumIntroDuration;
|
||||||
|
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
|
||||||
IReadOnlyList<QueuedEpisode> analysisQueue,
|
ReadOnlyCollection<QueuedEpisode> analysisQueue,
|
||||||
AnalysisMode mode,
|
AnalysisMode mode,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Episodes that were not analyzed.
|
|
||||||
var episodeAnalysisQueue = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
|
|
||||||
|
|
||||||
if (episodeAnalysisQueue.Count <= 1)
|
|
||||||
{
|
|
||||||
return analysisQueue;
|
|
||||||
}
|
|
||||||
|
|
||||||
_analysisMode = mode;
|
|
||||||
|
|
||||||
// All intros for this season.
|
// All intros for this season.
|
||||||
var seasonIntros = new Dictionary<Guid, Segment>();
|
var seasonIntros = new Dictionary<Guid, Intro>();
|
||||||
|
|
||||||
// Cache of all fingerprints for this season.
|
// Cache of all fingerprints for this season.
|
||||||
var fingerprintCache = new Dictionary<Guid, uint[]>();
|
var fingerprintCache = new Dictionary<Guid, uint[]>();
|
||||||
|
|
||||||
|
// Episode analysis queue.
|
||||||
|
var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue);
|
||||||
|
|
||||||
|
// Episodes that were analyzed and do not have an introduction.
|
||||||
|
var episodesWithoutIntros = new List<QueuedEpisode>();
|
||||||
|
|
||||||
|
this._analysisMode = mode;
|
||||||
|
|
||||||
// Compute fingerprints for all episodes in the season
|
// Compute fingerprints for all episodes in the season
|
||||||
foreach (var episode in episodeAnalysisQueue)
|
foreach (var episode in episodeAnalysisQueue)
|
||||||
{
|
{
|
||||||
@ -75,7 +95,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
|
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
|
||||||
|
|
||||||
// Fallback to an empty fingerprint on any error
|
// Fallback to an empty fingerprint on any error
|
||||||
fingerprintCache[episode.EpisodeId] = [];
|
fingerprintCache[episode.EpisodeId] = Array.Empty<uint>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,7 +121,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
// - the introduction exceeds the configured limit
|
// - the introduction exceeds the configured limit
|
||||||
if (
|
if (
|
||||||
!remainingIntro.Valid ||
|
!remainingIntro.Valid ||
|
||||||
(_analysisMode == AnalysisMode.Introduction && remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration))
|
remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -115,17 +135,17 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
* To fix this, the starting and ending times need to be switched, as they were previously reversed
|
* To fix this, the starting and ending times need to be switched, as they were previously reversed
|
||||||
* and subtracted from the episode duration to get the reported time range.
|
* and subtracted from the episode duration to get the reported time range.
|
||||||
*/
|
*/
|
||||||
if (_analysisMode == AnalysisMode.Credits)
|
if (this._analysisMode == AnalysisMode.Credits)
|
||||||
{
|
{
|
||||||
// Calculate new values for the current intro
|
// Calculate new values for the current intro
|
||||||
double currentOriginalIntroStart = currentIntro.Start;
|
double currentOriginalIntroStart = currentIntro.IntroStart;
|
||||||
currentIntro.Start = currentEpisode.Duration - currentIntro.End;
|
currentIntro.IntroStart = currentEpisode.Duration - currentIntro.IntroEnd;
|
||||||
currentIntro.End = currentEpisode.Duration - currentOriginalIntroStart;
|
currentIntro.IntroEnd = currentEpisode.Duration - currentOriginalIntroStart;
|
||||||
|
|
||||||
// Calculate new values for the remaining intro
|
// Calculate new values for the remaining intro
|
||||||
double remainingIntroOriginalStart = remainingIntro.Start;
|
double remainingIntroOriginalStart = remainingIntro.IntroStart;
|
||||||
remainingIntro.Start = remainingEpisode.Duration - remainingIntro.End;
|
remainingIntro.IntroStart = remainingEpisode.Duration - remainingIntro.IntroEnd;
|
||||||
remainingIntro.End = remainingEpisode.Duration - remainingIntroOriginalStart;
|
remainingIntro.IntroEnd = remainingEpisode.Duration - remainingIntroOriginalStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only save the discovered intro if it is:
|
// Only save the discovered intro if it is:
|
||||||
@ -148,17 +168,30 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If an intro is found for this episode, adjust its times and save it else add it to the list of episodes without intros.
|
// If no intro is found at this point, the popped episode is not reinserted into the queue.
|
||||||
if (seasonIntros.TryGetValue(currentEpisode.EpisodeId, out var intro))
|
if (!seasonIntros.ContainsKey(currentEpisode.EpisodeId))
|
||||||
{
|
{
|
||||||
currentEpisode.IsAnalyzed = true;
|
episodesWithoutIntros.Add(currentEpisode);
|
||||||
await Plugin.Instance!.UpdateTimestampAsync(intro, mode).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If cancellation was requested, report that no episodes were analyzed.
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
return analysisQueue;
|
return analysisQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._analysisMode == AnalysisMode.Introduction)
|
||||||
|
{
|
||||||
|
// Adjust all introduction end times so that they end at silence.
|
||||||
|
seasonIntros = AdjustIntroEndTimes(analysisQueue, seasonIntros);
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugin.Instance!.UpdateTimestamps(seasonIntros, this._analysisMode);
|
||||||
|
|
||||||
|
return episodesWithoutIntros.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Analyze two episodes to find an introduction sequence shared between them.
|
/// Analyze two episodes to find an introduction sequence shared between them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -167,7 +200,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
/// <param name="rhsId">Second episode id.</param>
|
/// <param name="rhsId">Second episode id.</param>
|
||||||
/// <param name="rhsPoints">Second episode fingerprint points.</param>
|
/// <param name="rhsPoints">Second episode fingerprint points.</param>
|
||||||
/// <returns>Intros for the first and second episodes.</returns>
|
/// <returns>Intros for the first and second episodes.</returns>
|
||||||
public (Segment Lhs, Segment Rhs) CompareEpisodes(
|
public (Intro Lhs, Intro Rhs) CompareEpisodes(
|
||||||
Guid lhsId,
|
Guid lhsId,
|
||||||
uint[] lhsPoints,
|
uint[] lhsPoints,
|
||||||
Guid rhsId,
|
Guid rhsId,
|
||||||
@ -189,7 +222,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
lhsId,
|
lhsId,
|
||||||
rhsId);
|
rhsId);
|
||||||
|
|
||||||
return (new Segment(lhsId), new Segment(rhsId));
|
return (new Intro(lhsId), new Intro(rhsId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -200,7 +233,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
/// <param name="rhsId">Second episode id.</param>
|
/// <param name="rhsId">Second episode id.</param>
|
||||||
/// <param name="rhsRanges">Second episode shared timecodes.</param>
|
/// <param name="rhsRanges">Second episode shared timecodes.</param>
|
||||||
/// <returns>Intros for the first and second episodes.</returns>
|
/// <returns>Intros for the first and second episodes.</returns>
|
||||||
private static (Segment Lhs, Segment Rhs) GetLongestTimeRange(
|
private (Intro Lhs, Intro Rhs) GetLongestTimeRange(
|
||||||
Guid lhsId,
|
Guid lhsId,
|
||||||
List<TimeRange> lhsRanges,
|
List<TimeRange> lhsRanges,
|
||||||
Guid rhsId,
|
Guid rhsId,
|
||||||
@ -225,7 +258,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create Intro classes for each time range.
|
// Create Intro classes for each time range.
|
||||||
return (new Segment(lhsId, lhsIntro), new Segment(rhsId, rhsIntro));
|
return (new Intro(lhsId, lhsIntro), new Intro(rhsId, rhsIntro));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -246,8 +279,8 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
var rhsRanges = new List<TimeRange>();
|
var rhsRanges = new List<TimeRange>();
|
||||||
|
|
||||||
// Generate inverted indexes for the left and right episodes.
|
// Generate inverted indexes for the left and right episodes.
|
||||||
var lhsIndex = CreateInvertedIndex(lhsId, lhsPoints);
|
var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, this._analysisMode);
|
||||||
var rhsIndex = CreateInvertedIndex(rhsId, rhsPoints);
|
var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, this._analysisMode);
|
||||||
var indexShifts = new HashSet<int>();
|
var indexShifts = new HashSet<int>();
|
||||||
|
|
||||||
// For all audio points in the left episode, check if the right episode has a point which matches exactly.
|
// For all audio points in the left episode, check if the right episode has a point which matches exactly.
|
||||||
@ -256,14 +289,14 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
{
|
{
|
||||||
var originalPoint = kvp.Key;
|
var originalPoint = kvp.Key;
|
||||||
|
|
||||||
for (var i = -1 * _config.InvertedIndexShift; i <= _config.InvertedIndexShift; i++)
|
for (var i = -1 * invertedIndexShift; i <= invertedIndexShift; i++)
|
||||||
{
|
{
|
||||||
var modifiedPoint = (uint)(originalPoint + i);
|
var modifiedPoint = (uint)(originalPoint + i);
|
||||||
|
|
||||||
if (rhsIndex.TryGetValue(modifiedPoint, out var rhsModifiedPoint))
|
if (rhsIndex.TryGetValue(modifiedPoint, out var rhsModifiedPoint))
|
||||||
{
|
{
|
||||||
var lhsFirst = lhsIndex[originalPoint];
|
var lhsFirst = (int)lhsIndex[originalPoint];
|
||||||
var rhsFirst = rhsModifiedPoint;
|
var rhsFirst = (int)rhsModifiedPoint;
|
||||||
indexShifts.Add(rhsFirst - lhsFirst);
|
indexShifts.Add(rhsFirst - lhsFirst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -321,7 +354,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
var diff = lhs[lhsPosition] ^ rhs[rhsPosition];
|
var diff = lhs[lhsPosition] ^ rhs[rhsPosition];
|
||||||
|
|
||||||
// If the difference between the samples is small, flag both times as similar.
|
// If the difference between the samples is small, flag both times as similar.
|
||||||
if (CountBits(diff) > _config.MaximumFingerprintPointDifferences)
|
if (CountBits(diff) > maximumDifferences)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -338,148 +371,112 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
rhsTimes.Add(double.MaxValue);
|
rhsTimes.Add(double.MaxValue);
|
||||||
|
|
||||||
// Now that both fingerprints have been compared at this shift, see if there's a contiguous time range.
|
// Now that both fingerprints have been compared at this shift, see if there's a contiguous time range.
|
||||||
var lContiguous = TimeRangeHelpers.FindContiguous([.. lhsTimes], _config.MaximumTimeSkip);
|
var lContiguous = TimeRangeHelpers.FindContiguous(lhsTimes.ToArray(), maximumTimeSkip);
|
||||||
if (lContiguous is null || lContiguous.Duration < _config.MinimumIntroDuration)
|
if (lContiguous is null || lContiguous.Duration < minimumIntroDuration)
|
||||||
{
|
{
|
||||||
return (new TimeRange(), new TimeRange());
|
return (new TimeRange(), new TimeRange());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Since LHS had a contiguous time range, RHS must have one also.
|
// Since LHS had a contiguous time range, RHS must have one also.
|
||||||
var rContiguous = TimeRangeHelpers.FindContiguous([.. rhsTimes], _config.MaximumTimeSkip)!;
|
var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), maximumTimeSkip)!;
|
||||||
|
|
||||||
|
if (this._analysisMode == AnalysisMode.Introduction)
|
||||||
|
{
|
||||||
|
// Tweak the end timestamps just a bit to ensure as little content as possible is skipped over.
|
||||||
|
// TODO: remove this
|
||||||
|
if (lContiguous.Duration >= 90)
|
||||||
|
{
|
||||||
|
lContiguous.End -= 2 * maximumTimeSkip;
|
||||||
|
rContiguous.End -= 2 * maximumTimeSkip;
|
||||||
|
}
|
||||||
|
else if (lContiguous.Duration >= 30)
|
||||||
|
{
|
||||||
|
lContiguous.End -= maximumTimeSkip;
|
||||||
|
rContiguous.End -= maximumTimeSkip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (lContiguous, rContiguous);
|
return (lContiguous, rContiguous);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adjusts the end timestamps of all intros so that they end at silence.
|
/// Adjusts the end timestamps of all intros so that they end at silence.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="episode">QueuedEpisode to adjust.</param>
|
/// <param name="episodes">QueuedEpisodes to adjust.</param>
|
||||||
/// <param name="originalIntro">Original introduction.</param>
|
/// <param name="originalIntros">Original introductions.</param>
|
||||||
private Segment AdjustIntroTimes(
|
private Dictionary<Guid, Intro> AdjustIntroEndTimes(
|
||||||
QueuedEpisode episode,
|
ReadOnlyCollection<QueuedEpisode> episodes,
|
||||||
Segment originalIntro)
|
Dictionary<Guid, Intro> originalIntros)
|
||||||
{
|
{
|
||||||
|
// The minimum duration of audio that must be silent before adjusting the intro's end.
|
||||||
|
var minimumSilence = Plugin.Instance!.Configuration.SilenceDetectionMinimumDuration;
|
||||||
|
|
||||||
|
Dictionary<Guid, Intro> modifiedIntros = new();
|
||||||
|
|
||||||
|
// For all episodes
|
||||||
|
foreach (var episode in episodes)
|
||||||
|
{
|
||||||
|
_logger.LogTrace(
|
||||||
|
"Adjusting introduction end time for {Name} ({Id})",
|
||||||
|
episode.Name,
|
||||||
|
episode.EpisodeId);
|
||||||
|
|
||||||
|
// If no intro was found for this episode, skip it.
|
||||||
|
if (!originalIntros.TryGetValue(episode.EpisodeId, out var originalIntro))
|
||||||
|
{
|
||||||
|
_logger.LogTrace("{Name} does not have an intro", episode.Name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only adjust the end timestamp of the intro
|
||||||
|
var originalIntroEnd = new TimeRange(originalIntro.IntroEnd - 15, originalIntro.IntroEnd);
|
||||||
|
|
||||||
_logger.LogTrace(
|
_logger.LogTrace(
|
||||||
"{Name} original intro: {Start} - {End}",
|
"{Name} original intro: {Start} - {End}",
|
||||||
episode.Name,
|
episode.Name,
|
||||||
originalIntro.Start,
|
originalIntro.IntroStart,
|
||||||
originalIntro.End);
|
originalIntro.IntroEnd);
|
||||||
|
|
||||||
var originalIntroStart = new TimeRange(
|
// Detect silence in the media file up to the end of the intro.
|
||||||
Math.Max(0, (int)originalIntro.Start - 5),
|
var silence = FFmpegWrapper.DetectSilence(episode, (int)originalIntro.IntroEnd + 2);
|
||||||
(int)originalIntro.Start + 10);
|
|
||||||
|
|
||||||
var originalIntroEnd = new TimeRange(
|
// For all periods of silence
|
||||||
(int)originalIntro.End - 10,
|
foreach (var currentRange in silence)
|
||||||
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} silence: {Start} - {End}",
|
||||||
|
episode.Name,
|
||||||
|
currentRange.Start,
|
||||||
|
currentRange.End);
|
||||||
|
|
||||||
|
// Ignore any silence that:
|
||||||
|
// * doesn't intersect the ending of the intro, or
|
||||||
|
// * is shorter than the user defined minimum duration, or
|
||||||
|
// * starts before the introduction does
|
||||||
|
if (
|
||||||
|
!originalIntroEnd.Intersects(currentRange) ||
|
||||||
|
currentRange.Duration < silenceDetectionMinimumDuration ||
|
||||||
|
currentRange.Start < originalIntro.IntroStart)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust the end timestamp of the intro to match the start of the silence region.
|
||||||
|
originalIntro.IntroEnd = currentRange.Start;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogTrace(
|
_logger.LogTrace(
|
||||||
"{Name} adjusted intro: {Start} - {End}",
|
"{Name} adjusted intro: {Start} - {End}",
|
||||||
episode.Name,
|
episode.Name,
|
||||||
originalIntro.Start,
|
originalIntro.IntroStart,
|
||||||
originalIntro.End);
|
originalIntro.IntroEnd);
|
||||||
|
|
||||||
return originalIntro;
|
// Add the (potentially) modified intro back.
|
||||||
|
modifiedIntros[episode.EpisodeId] = originalIntro;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool AdjustIntroBasedOnChapters(
|
return modifiedIntros;
|
||||||
QueuedEpisode episode,
|
|
||||||
Segment intro,
|
|
||||||
TimeRange originalIntroStart,
|
|
||||||
TimeRange originalIntroEnd)
|
|
||||||
{
|
|
||||||
var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? [];
|
|
||||||
double previousTime = 0;
|
|
||||||
|
|
||||||
for (int i = 0; i <= chapters.Count; i++)
|
|
||||||
{
|
|
||||||
double currentTime = i < chapters.Count
|
|
||||||
? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds
|
|
||||||
: episode.Duration;
|
|
||||||
|
|
||||||
if (IsTimeWithinRange(previousTime, originalIntroStart))
|
|
||||||
{
|
|
||||||
intro.Start = previousTime;
|
|
||||||
_logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsTimeWithinRange(currentTime, originalIntroEnd))
|
|
||||||
{
|
|
||||||
intro.End = currentTime;
|
|
||||||
_logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
previousTime = currentTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment intro, TimeRange originalIntroEnd)
|
|
||||||
{
|
|
||||||
var silenceRanges = FFmpegWrapper.DetectSilence(episode, originalIntroEnd);
|
|
||||||
|
|
||||||
foreach (var silenceRange in silenceRanges)
|
|
||||||
{
|
|
||||||
_logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, silenceRange.Start, silenceRange.End);
|
|
||||||
|
|
||||||
if (IsValidSilenceForIntroAdjustment(silenceRange, originalIntroEnd, intro))
|
|
||||||
{
|
|
||||||
intro.End = silenceRange.Start;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsValidSilenceForIntroAdjustment(
|
|
||||||
TimeRange silenceRange,
|
|
||||||
TimeRange originalIntroEnd,
|
|
||||||
Segment adjustedIntro)
|
|
||||||
{
|
|
||||||
return originalIntroEnd.Intersects(silenceRange) &&
|
|
||||||
silenceRange.Duration >= _config.SilenceDetectionMinimumDuration &&
|
|
||||||
silenceRange.Start >= adjustedIntro.Start;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsTimeWithinRange(double time, TimeRange range)
|
|
||||||
{
|
|
||||||
return range.Start < time && time < range.End;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">Episode ID.</param>
|
|
||||||
/// <param name="fingerprint">Chromaprint fingerprint.</param>
|
|
||||||
/// <returns>Inverted index.</returns>
|
|
||||||
public Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint)
|
|
||||||
{
|
|
||||||
if (_invertedIndexCache.TryGetValue(id, out var cached))
|
|
||||||
{
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
var invIndex = new Dictionary<uint, int>();
|
|
||||||
|
|
||||||
for (int i = 0; i < fingerprint.Length; i++)
|
|
||||||
{
|
|
||||||
// Get the current point.
|
|
||||||
var point = fingerprint[i];
|
|
||||||
|
|
||||||
// Append the current sample's timecode to the collection for this point.
|
|
||||||
invIndex[point] = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
_invertedIndexCache[id] = invIndex;
|
|
||||||
|
|
||||||
return invIndex;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -487,7 +484,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="number">Number to count bits in.</param>
|
/// <param name="number">Number to count bits in.</param>
|
||||||
/// <returns>Number of bits that are equal to 1.</returns>
|
/// <returns>Number of bits that are equal to 1.</returns>
|
||||||
public static int CountBits(uint number)
|
public int CountBits(uint number)
|
||||||
{
|
{
|
||||||
return BitOperations.PopCount(number);
|
return BitOperations.PopCount(number);
|
||||||
}
|
}
|
@ -1,12 +1,10 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System.Collections.Generic;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IntroSkipper.Data;
|
|
||||||
|
|
||||||
namespace IntroSkipper.Analyzers;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Media file analyzer interface.
|
/// Media file analyzer interface.
|
||||||
@ -20,8 +18,8 @@ public interface IMediaFileAnalyzer
|
|||||||
/// <param name="mode">Analysis mode.</param>
|
/// <param name="mode">Analysis mode.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token from scheduled task.</param>
|
/// <param name="cancellationToken">Cancellation token from scheduled task.</param>
|
||||||
/// <returns>Collection of media files that were **unsuccessfully analyzed**.</returns>
|
/// <returns>Collection of media files that were **unsuccessfully analyzed**.</returns>
|
||||||
Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
|
||||||
IReadOnlyList<QueuedEpisode> analysisQueue,
|
ReadOnlyCollection<QueuedEpisode> analysisQueue,
|
||||||
AnalysisMode mode,
|
AnalysisMode mode,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chapter name analyzer.
|
||||||
|
/// </summary>
|
||||||
|
public class SegmentAnalyzer : IMediaFileAnalyzer
|
||||||
|
{
|
||||||
|
private 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 ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
|
||||||
|
ReadOnlyCollection<QueuedEpisode> analysisQueue,
|
||||||
|
AnalysisMode mode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return analysisQueue;
|
||||||
|
}
|
||||||
|
}
|
231
ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs
Normal file
231
ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Timers;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Session;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
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>
|
||||||
|
public class AutoSkip : IServerEntryPoint
|
||||||
|
{
|
||||||
|
private readonly object _sentSeekCommandLock = new();
|
||||||
|
|
||||||
|
private ILogger<AutoSkip> _logger;
|
||||||
|
private IUserDataManager _userDataManager;
|
||||||
|
private ISessionManager _sessionManager;
|
||||||
|
private System.Timers.Timer _playbackTimer = new(1000);
|
||||||
|
private Dictionary<string, bool> _sentSeekCommand;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userDataManager">User data manager.</param>
|
||||||
|
/// <param name="sessionManager">Session manager.</param>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public AutoSkip(
|
||||||
|
IUserDataManager userDataManager,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
ILogger<AutoSkip> logger)
|
||||||
|
{
|
||||||
|
_userDataManager = userDataManager;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_logger = logger;
|
||||||
|
_sentSeekCommand = new Dictionary<string, bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If introduction auto skipping is enabled, set it up.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Task.</returns>
|
||||||
|
public Task RunAsync()
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Setting up automatic skipping");
|
||||||
|
|
||||||
|
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
|
||||||
|
Plugin.Instance!.AutoSkipChanged += AutoSkipChanged;
|
||||||
|
|
||||||
|
// Make the timer restart automatically and set enabled to match the configuration value.
|
||||||
|
_playbackTimer.AutoReset = true;
|
||||||
|
_playbackTimer.Elapsed += PlaybackTimer_Elapsed;
|
||||||
|
|
||||||
|
AutoSkipChanged(null, EventArgs.Empty);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AutoSkipChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var newState = Plugin.Instance!.Configuration.AutoSkip;
|
||||||
|
_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)
|
||||||
|
{
|
||||||
|
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(5, intro.IntroStart + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay);
|
||||||
|
var adjustedEnd = intro.IntroEnd - 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("N"),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
|
||||||
|
_playbackTimer.Stop();
|
||||||
|
_playbackTimer.Dispose();
|
||||||
|
}
|
||||||
|
}
|
225
ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs
Normal file
225
ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Timers;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Session;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
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>
|
||||||
|
public class AutoSkipCredits : IServerEntryPoint
|
||||||
|
{
|
||||||
|
private readonly object _sentSeekCommandLock = new();
|
||||||
|
|
||||||
|
private ILogger<AutoSkipCredits> _logger;
|
||||||
|
private IUserDataManager _userDataManager;
|
||||||
|
private ISessionManager _sessionManager;
|
||||||
|
private System.Timers.Timer _playbackTimer = new(1000);
|
||||||
|
private Dictionary<string, bool> _sentSeekCommand;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AutoSkipCredits"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userDataManager">User data manager.</param>
|
||||||
|
/// <param name="sessionManager">Session manager.</param>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public AutoSkipCredits(
|
||||||
|
IUserDataManager userDataManager,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
ILogger<AutoSkipCredits> logger)
|
||||||
|
{
|
||||||
|
_userDataManager = userDataManager;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_logger = logger;
|
||||||
|
_sentSeekCommand = new Dictionary<string, bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If credits auto skipping is enabled, set it up.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Task.</returns>
|
||||||
|
public Task RunAsync()
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Setting up automatic skipping");
|
||||||
|
|
||||||
|
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
|
||||||
|
Plugin.Instance!.AutoSkipCreditsChanged += AutoSkipCreditsChanged;
|
||||||
|
|
||||||
|
// Make the timer restart automatically and set enabled to match the configuration value.
|
||||||
|
_playbackTimer.AutoReset = true;
|
||||||
|
_playbackTimer.Elapsed += PlaybackTimer_Elapsed;
|
||||||
|
|
||||||
|
AutoSkipCreditsChanged(null, EventArgs.Empty);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AutoSkipCreditsChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var newState = Plugin.Instance!.Configuration.AutoSkipCredits;
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
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.IntroStart + Plugin.Instance.Configuration.SecondsOfCreditsStartToPlay;
|
||||||
|
var adjustedEnd = credit.IntroEnd - 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("N"),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
|
||||||
|
_playbackTimer.Stop();
|
||||||
|
_playbackTimer.Dispose();
|
||||||
|
}
|
||||||
|
}
|
@ -3,10 +3,9 @@
|
|||||||
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using IntroSkipper.Data;
|
|
||||||
using MediaBrowser.Model.Plugins;
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
namespace IntroSkipper.Configuration;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plugin configuration.
|
/// Plugin configuration.
|
||||||
@ -23,34 +22,34 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
// ===== Analysis settings =====
|
// ===== Analysis settings =====
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the comma separated list of library names to analyze.
|
/// 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. If empty, all libraries will be analyzed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string SelectedLibraries { get; set; } = string.Empty;
|
public string SelectedLibraries { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether all libraries should be analyzed.
|
/// Gets a temporary limitation on file paths to be analyzed. Should be empty when automatic scan is idle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool SelectAllLibraries { get; set; } = true;
|
public IList<string> PathRestrictions { get; } = new List<string>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether movies should be analyzed.
|
/// Gets or sets a value indicating whether to scan for intros during a scheduled task.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AnalyzeMovies { get; set; }
|
public bool AutoDetectIntros { get; set; } = false;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of client to auto skip for.
|
/// Gets or sets a value indicating whether to scan for credits during a scheduled task.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ClientList { get; set; } = string.Empty;
|
public bool AutoDetectCredits { get; set; } = false;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether to automatically scan newly added items.
|
|
||||||
/// </summary>
|
|
||||||
public bool AutoDetectIntros { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether to analyze season 0.
|
/// Gets or sets a value indicating whether to analyze season 0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AnalyzeSeasonZero { get; set; }
|
public bool AnalyzeSeasonZero { get; set; } = false;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem.
|
/// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem.
|
||||||
@ -60,44 +59,24 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether analysis will use Chromaprint to determine fingerprints.
|
/// Gets or sets a value indicating whether analysis will use Chromaprint to determine fingerprints.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool WithChromaprint { get; set; } = true;
|
public bool UseChromaprint { get; set; } = true;
|
||||||
|
|
||||||
// ===== Media Segment handling =====
|
// ===== EDL handling =====
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether to update Media Segments.
|
/// Gets or sets a value indicating the action to write to created EDL files.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool UpdateMediaSegments { get; set; } = true;
|
public EdlAction EdlAction { get; set; } = EdlAction.None;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether to regenerate all Media Segments during the next scan.
|
/// Gets or sets a value indicating whether to regenerate all EDL files during the next scan.
|
||||||
/// By default, Media Segments are only written for a season if the season had at least one newly analyzed episode.
|
/// By default, EDL files are only written for a season if the season had at least one newly analyzed episode.
|
||||||
/// If this is set, all Media Segments will be regenerated and overwrite any existing Media Segemnts.
|
/// If this is set, all EDL files will be regenerated and overwrite any existing EDL file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool RebuildMediaSegments { get; set; } = true;
|
public bool RegenerateEdlFiles { get; set; } = false;
|
||||||
|
|
||||||
// ===== Custom analysis settings =====
|
// ===== Custom analysis settings =====
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether Introductions should be analyzed.
|
|
||||||
/// </summary>
|
|
||||||
public bool ScanIntroduction { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether Credits should be analyzed.
|
|
||||||
/// </summary>
|
|
||||||
public bool ScanCredits { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether Recaps should be analyzed.
|
|
||||||
/// </summary>
|
|
||||||
public bool ScanRecap { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether Previews should be analyzed.
|
|
||||||
/// </summary>
|
|
||||||
public bool ScanPreview { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the percentage of each episode's audio track to analyze.
|
/// Gets or sets the percentage of each episode's audio track to analyze.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -126,12 +105,7 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// <summary>
|
/// <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.
|
/// 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>
|
/// </summary>
|
||||||
public int MaximumCreditsDuration { get; set; } = 450;
|
public int MaximumCreditsDuration { get; set; } = 300;
|
||||||
|
|
||||||
/// <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>
|
/// <summary>
|
||||||
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
|
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
|
||||||
@ -142,68 +116,31 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// Gets or sets the regular expression used to detect introduction chapters.
|
/// Gets or sets the regular expression used to detect introduction chapters.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ChapterAnalyzerIntroductionPattern { get; set; } =
|
public string ChapterAnalyzerIntroductionPattern { get; set; } =
|
||||||
@"(^|\s)(Intro|Introduction|OP|Opening)(?!\sEnd)(\s|$)";
|
@"(^|\s)(Intro|Introduction|OP|Opening)(\s|$)";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the regular expression used to detect ending credit chapters.
|
/// Gets or sets the regular expression used to detect ending credit chapters.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ChapterAnalyzerEndCreditsPattern { get; set; } =
|
public string ChapterAnalyzerEndCreditsPattern { get; set; } =
|
||||||
@"(^|\s)(Credits?|ED|Ending|Outro)(?!\sEnd)(\s|$)";
|
@"(^|\s)(Credits?|ED|Ending|End|Outro)(\s|$)";
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the regular expression used to detect Preview chapters.
|
|
||||||
/// </summary>
|
|
||||||
public string ChapterAnalyzerPreviewPattern { get; set; } =
|
|
||||||
@"(^|\s)(Preview|PV|Sneak\s?Peek|Coming\s?(Up|Soon)|Next\s+(time|on|episode)|Extra|Teaser|Trailer)(?!\sEnd)(\s|:|$)";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the regular expression used to detect Recap chapters.
|
|
||||||
/// </summary>
|
|
||||||
public string ChapterAnalyzerRecapPattern { get; set; } =
|
|
||||||
@"(^|\s)(Re?cap|Sum{1,2}ary|Prev(ious(ly)?)?|(Last|Earlier)(\s\w+)?|Catch[ -]up)(?!\sEnd)(\s|:|$)";
|
|
||||||
|
|
||||||
// ===== Playback settings =====
|
// ===== Playback settings =====
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether to show the skip intro button.
|
/// Gets or sets a value indicating whether to show the skip intro button.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool SkipButtonEnabled { get; set; }
|
public bool SkipButtonVisible { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether introductions should be automatically skipped.
|
/// Gets or sets a value indicating whether introductions should be automatically skipped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AutoSkip { get; set; }
|
public bool AutoSkip { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of segment types to auto skip.
|
|
||||||
/// </summary>
|
|
||||||
public string TypeList { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether credits should be automatically skipped.
|
/// Gets or sets a value indicating whether credits should be automatically skipped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AutoSkipCredits { get; set; }
|
public bool AutoSkipCredits { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether recap should be automatically skipped.
|
|
||||||
/// </summary>
|
|
||||||
public bool AutoSkipRecap { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether preview should be automatically skipped.
|
|
||||||
/// </summary>
|
|
||||||
public bool AutoSkipPreview { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the seconds before the intro starts to show the skip prompt at.
|
/// Gets or sets the seconds before the intro starts to show the skip prompt at.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -232,7 +169,12 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the amount of intro at start to play (in seconds).
|
/// Gets or sets the amount of intro at start to play (in seconds).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int SecondsOfIntroStartToPlay { get; set; }
|
public int SecondsOfIntroStartToPlay { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the amount of credit at start to play (in seconds).
|
||||||
|
/// </summary>
|
||||||
|
public int SecondsOfCreditsStartToPlay { get; set; } = 0;
|
||||||
|
|
||||||
// ===== Internal algorithm settings =====
|
// ===== Internal algorithm settings =====
|
||||||
|
|
||||||
@ -278,25 +220,20 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the notification text sent after automatically skipping an introduction.
|
/// Gets or sets the notification text sent after automatically skipping an introduction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string AutoSkipNotificationText { get; set; } = "Segment skipped";
|
public string AutoSkipNotificationText { get; set; } = "Intro skipped";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the max degree of parallelism used when analyzing episodes.
|
/// Gets or sets the notification text sent after automatically skipping credits.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int MaxParallelism { get; set; } = 2;
|
public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the number of threads for a ffmpeg process.
|
/// Gets or sets the number of threads for an ffmpeg process.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ProcessThreads { get; set; }
|
public int ProcessThreads { get; set; } = 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the relative priority for a ffmpeg process.
|
/// Gets or sets the relative priority for an ffmpeg process.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal;
|
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; }
|
|
||||||
}
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User interface configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class UserInterfaceConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="UserInterfaceConfiguration"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <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 UserInterfaceConfiguration(bool visible, string introText, string creditsText)
|
||||||
|
{
|
||||||
|
SkipButtonVisible = visible;
|
||||||
|
SkipButtonIntroText = introText;
|
||||||
|
SkipButtonEndCreditsText = creditsText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to show the skip intro button.
|
||||||
|
/// </summary>
|
||||||
|
public bool SkipButtonVisible { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the text to display in the skip intro button in introduction mode.
|
||||||
|
/// </summary>
|
||||||
|
public string SkipButtonIntroText { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the text to display in the skip intro button in end credits mode.
|
||||||
|
/// </summary>
|
||||||
|
public string SkipButtonEndCreditsText { get; set; }
|
||||||
|
}
|
1267
ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html
Normal file
1267
ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html
Normal file
File diff suppressed because it is too large
Load Diff
225
ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js
Normal file
225
ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
let introSkipper = {
|
||||||
|
skipSegments: {},
|
||||||
|
videoPlayer: {},
|
||||||
|
// .bind() is used here to prevent illegal invocation errors
|
||||||
|
originalFetch: window.fetch.bind(window),
|
||||||
|
};
|
||||||
|
introSkipper.d = function (msg) {
|
||||||
|
console.debug("[intro skipper] ", msg);
|
||||||
|
}
|
||||||
|
/** Setup event listeners */
|
||||||
|
introSkipper.setup = function () {
|
||||||
|
document.addEventListener("viewshow", introSkipper.viewShow);
|
||||||
|
window.fetch = introSkipper.fetchWrapper;
|
||||||
|
introSkipper.d("Registered hooks");
|
||||||
|
}
|
||||||
|
/** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
|
||||||
|
introSkipper.fetchWrapper = async function (...args) {
|
||||||
|
// Based on JellyScrub's trickplay.js
|
||||||
|
let [resource, options] = args;
|
||||||
|
let response = await introSkipper.originalFetch(resource, options);
|
||||||
|
// Bail early if this isn't a playback info URL
|
||||||
|
try {
|
||||||
|
let path = new URL(resource).pathname;
|
||||||
|
if (!path.includes("/PlaybackInfo")) { return response; }
|
||||||
|
introSkipper.d("Retrieving skip segments from URL");
|
||||||
|
introSkipper.d(path);
|
||||||
|
|
||||||
|
// Check for context root and set id accordingly
|
||||||
|
let path_arr = path.split("/");
|
||||||
|
let id = "";
|
||||||
|
if (path_arr[1] == "Items") {
|
||||||
|
id = path_arr[2];
|
||||||
|
} else {
|
||||||
|
id = path_arr[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
introSkipper.skipSegments = await introSkipper.secureFetch(`Episode/${id}/IntroSkipperSegments`);
|
||||||
|
introSkipper.d("Successfully retrieved skip segments");
|
||||||
|
introSkipper.d(introSkipper.skipSegments);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error("Unable to get skip segments from", resource, e);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Event handler that runs whenever the current view changes.
|
||||||
|
* Used to detect the start of video playback.
|
||||||
|
*/
|
||||||
|
introSkipper.viewShow = function () {
|
||||||
|
const location = window.location.hash;
|
||||||
|
introSkipper.d("Location changed to " + location);
|
||||||
|
if (location !== "#!/video") {
|
||||||
|
introSkipper.d("Ignoring location change");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
introSkipper.injectCss();
|
||||||
|
introSkipper.injectButton();
|
||||||
|
introSkipper.videoPlayer = document.querySelector("video");
|
||||||
|
if (introSkipper.videoPlayer != null) {
|
||||||
|
introSkipper.d("Hooking video timeupdate");
|
||||||
|
introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Injects the CSS used by the skip intro button.
|
||||||
|
* Calling this function is a no-op if the CSS has already been injected.
|
||||||
|
*/
|
||||||
|
introSkipper.injectCss = function () {
|
||||||
|
if (introSkipper.testElement("style#introSkipperCss")) {
|
||||||
|
introSkipper.d("CSS already added");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
introSkipper.d("Adding CSS");
|
||||||
|
let styleElement = document.createElement("style");
|
||||||
|
styleElement.id = "introSkipperCss";
|
||||||
|
styleElement.innerText = `
|
||||||
|
:root {
|
||||||
|
--rounding: .2em;
|
||||||
|
--accent: 0, 164, 220;
|
||||||
|
}
|
||||||
|
#skipIntro.upNextContainer {
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
|
#skipIntro {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6em;
|
||||||
|
right: 4.5em;
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
#skipIntro .emby-button {
|
||||||
|
text-shadow: 0 0 3px rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: var(--rounding);
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
will-change: opacity, transform;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in, transform 0.3s ease-out;
|
||||||
|
}
|
||||||
|
#skipIntro .emby-button:hover,
|
||||||
|
#skipIntro .emby-button:focus {
|
||||||
|
background-color: rgba(var(--accent),0.7);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
#btnSkipSegmentText {
|
||||||
|
padding-right: 0.15em;
|
||||||
|
padding-left: 0.2em;
|
||||||
|
margin-top: -0.1em;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
introSkipper.injectButton = async function () {
|
||||||
|
// Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
|
||||||
|
const preExistingButton = introSkipper.testElement("div.skipIntro");
|
||||||
|
if (preExistingButton) {
|
||||||
|
preExistingButton.style.display = "none";
|
||||||
|
}
|
||||||
|
if (introSkipper.testElement(".btnSkipIntro.injected")) {
|
||||||
|
introSkipper.d("Button already added");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
introSkipper.d("Adding button");
|
||||||
|
let config = await introSkipper.secureFetch("Intros/UserInterfaceConfiguration");
|
||||||
|
if (!config.SkipButtonVisible) {
|
||||||
|
introSkipper.d("Not adding button: not visible");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Construct the skip button div
|
||||||
|
const button = document.createElement("div");
|
||||||
|
button.id = "skipIntro"
|
||||||
|
button.classList.add("hide");
|
||||||
|
button.addEventListener("click", introSkipper.doSkip);
|
||||||
|
button.innerHTML = `
|
||||||
|
<button is="emby-button" type="button" class="btnSkipIntro injected">
|
||||||
|
<span id="btnSkipSegmentText"></span>
|
||||||
|
<span class="material-icons skip_next"></span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
button.dataset["intro_text"] = config.SkipButtonIntroText;
|
||||||
|
button.dataset["credits_text"] = config.SkipButtonEndCreditsText;
|
||||||
|
/*
|
||||||
|
* Alternative workaround for #44. Jellyfin's video component registers a global click handler
|
||||||
|
* (located at src/controllers/playback/video/index.js:1492) that pauses video playback unless
|
||||||
|
* the clicked element has a parent with the class "videoOsdBottom" or "upNextContainer".
|
||||||
|
*/
|
||||||
|
button.classList.add("upNextContainer");
|
||||||
|
// Append the button to the video OSD
|
||||||
|
let controls = document.querySelector("div#videoOsdPage");
|
||||||
|
controls.appendChild(button);
|
||||||
|
}
|
||||||
|
/** Tests if the OSD controls are visible. */
|
||||||
|
introSkipper.osdVisible = function () {
|
||||||
|
const osd = document.querySelector("div.videoOsdBottom");
|
||||||
|
return osd ? !osd.classList.contains("hide") : false;
|
||||||
|
}
|
||||||
|
/** Get the currently playing skippable segment. */
|
||||||
|
introSkipper.getCurrentSegment = function (position) {
|
||||||
|
for (let key in introSkipper.skipSegments) {
|
||||||
|
const segment = introSkipper.skipSegments[key];
|
||||||
|
if ((position >= segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt) || (introSkipper.osdVisible() && position >= segment.IntroStart && position < segment.IntroEnd)) {
|
||||||
|
segment["SegmentType"] = key;
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { "SegmentType": "None" };
|
||||||
|
}
|
||||||
|
/** Playback position changed, check if the skip button needs to be displayed. */
|
||||||
|
introSkipper.videoPositionChanged = function () {
|
||||||
|
const skipButton = document.querySelector("#skipIntro");
|
||||||
|
if (!skipButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const embyButton = skipButton.querySelector(".emby-button");
|
||||||
|
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
|
||||||
|
switch (segment.SegmentType) {
|
||||||
|
case "None":
|
||||||
|
if (embyButton.style.opacity === '0') return;
|
||||||
|
|
||||||
|
embyButton.style.opacity = '0';
|
||||||
|
embyButton.addEventListener("transitionend", () => {
|
||||||
|
skipButton.classList.add("hide");
|
||||||
|
}, { once: true });
|
||||||
|
return;
|
||||||
|
case "Introduction":
|
||||||
|
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.intro_text;
|
||||||
|
break;
|
||||||
|
case "Credits":
|
||||||
|
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.credits_text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!skipButton.classList.contains("hide")) return;
|
||||||
|
|
||||||
|
skipButton.classList.remove("hide");
|
||||||
|
embyButton.offsetWidth; // Force reflow
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
embyButton.style.opacity = '1';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** Seeks to the end of the intro. */
|
||||||
|
introSkipper.doSkip = function (e) {
|
||||||
|
introSkipper.d("Skipping intro");
|
||||||
|
introSkipper.d(introSkipper.skipSegments);
|
||||||
|
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
|
||||||
|
if (segment["SegmentType"] === "None") {
|
||||||
|
console.warn("[intro skipper] doSkip() called without an active segment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
introSkipper.videoPlayer.currentTime = segment["IntroEnd"];
|
||||||
|
}
|
||||||
|
/** Tests if an element with the provided selector exists. */
|
||||||
|
introSkipper.testElement = function (selector) { return document.querySelector(selector); }
|
||||||
|
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
|
||||||
|
introSkipper.secureFetch = async function (url) {
|
||||||
|
url = ApiClient.serverAddress() + "/" + url;
|
||||||
|
const reqInit = { headers: { "Authorization": "MediaBrowser Token=" + ApiClient.accessToken() } };
|
||||||
|
const res = await fetch(url, reqInit);
|
||||||
|
if (res.status !== 200) { throw new Error(`Expected status 200 from ${url}, but got ${res.status}`); }
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
introSkipper.setup();
|
@ -0,0 +1 @@
|
|||||||
|
unknown
|
@ -1,9 +1,9 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
<RootNamespace>IntroSkipper</RootNamespace>
|
<RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace>
|
||||||
<AssemblyVersion>1.10.10.11</AssemblyVersion>
|
<AssemblyVersion>0.10.8.1</AssemblyVersion>
|
||||||
<FileVersion>1.10.10.11</FileVersion>
|
<FileVersion>0.10.8.1</FileVersion>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
@ -11,10 +11,8 @@
|
|||||||
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.*-*" />
|
<PackageReference Include="Jellyfin.Controller" Version="10.8.*" />
|
||||||
<PackageReference Include="Jellyfin.Model" Version="10.10.*-*" />
|
<PackageReference Include="Jellyfin.Model" Version="10.8.*" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
|
||||||
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
||||||
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
||||||
<PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.556" PrivateAssets="All" />
|
<PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.556" PrivateAssets="All" />
|
||||||
@ -24,5 +22,6 @@
|
|||||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
<EmbeddedResource Include="Configuration\visualizer.js" />
|
<EmbeddedResource Include="Configuration\visualizer.js" />
|
||||||
<EmbeddedResource Include="Configuration\inject.js" />
|
<EmbeddedResource Include="Configuration\inject.js" />
|
||||||
|
<EmbeddedResource Include="Configuration\version.txt" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
@ -0,0 +1,261 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Mime;
|
||||||
|
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||||
|
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||||
|
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 = "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.IntroEnd > 0.0)
|
||||||
|
{
|
||||||
|
var tr = new TimeRange(timestamps.Introduction.IntroStart, timestamps.Introduction.IntroEnd);
|
||||||
|
Plugin.Instance!.Intros[id] = new Intro(id, tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamps?.Credits.IntroEnd > 0.0)
|
||||||
|
{
|
||||||
|
var cr = new TimeRange(timestamps.Credits.IntroStart, timestamps.Credits.IntroEnd);
|
||||||
|
Plugin.Instance!.Credits[id] = new Intro(id, cr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugin.Instance!.SaveTimestamps();
|
||||||
|
|
||||||
|
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 Intro? GetIntro(Guid id, AnalysisMode mode)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var timestamp = mode == AnalysisMode.Introduction ?
|
||||||
|
Plugin.Instance!.Intros[id] :
|
||||||
|
Plugin.Instance!.Credits[id];
|
||||||
|
|
||||||
|
// 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 - 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
|
||||||
|
segment.HideSkipPromptAt = Math.Min(
|
||||||
|
segment.IntroStart + config.HidePromptAdjustment,
|
||||||
|
segment.IntroEnd - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erases all previously discovered introduction timestamps.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mode">Mode.</param>
|
||||||
|
/// <response code="204">Operation successful.</response>
|
||||||
|
/// <returns>No content.</returns>
|
||||||
|
[Authorize(Policy = "RequiresElevation")]
|
||||||
|
[HttpPost("Intros/EraseTimestamps")]
|
||||||
|
public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode)
|
||||||
|
{
|
||||||
|
if (mode == AnalysisMode.Introduction)
|
||||||
|
{
|
||||||
|
Plugin.Instance!.Intros.Clear();
|
||||||
|
}
|
||||||
|
else if (mode == AnalysisMode.Credits)
|
||||||
|
{
|
||||||
|
Plugin.Instance!.Credits.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugin.Instance!.SaveTimestamps();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all introductions or credits. Only used by the end to end testing script.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mode">Mode.</param>
|
||||||
|
/// <response code="200">All timestamps have been returned.</response>
|
||||||
|
/// <returns>List of IntroWithMetadata objects.</returns>
|
||||||
|
[Authorize(Policy = "RequiresElevation")]
|
||||||
|
[HttpGet("Intros/All")]
|
||||||
|
public ActionResult<List<IntroWithMetadata>> GetAllTimestamps(
|
||||||
|
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
|
||||||
|
{
|
||||||
|
List<IntroWithMetadata> intros = new();
|
||||||
|
|
||||||
|
var timestamps = mode == AnalysisMode.Introduction ?
|
||||||
|
Plugin.Instance!.Intros :
|
||||||
|
Plugin.Instance!.Credits;
|
||||||
|
|
||||||
|
// Get metadata for all intros
|
||||||
|
foreach (var intro in timestamps)
|
||||||
|
{
|
||||||
|
// Get the details of the item from Jellyfin
|
||||||
|
var rawItem = Plugin.Instance.GetItem(intro.Key);
|
||||||
|
if (rawItem == null || rawItem is not Episode episode)
|
||||||
|
{
|
||||||
|
throw new InvalidCastException("Unable to cast item id " + intro.Key + " to an Episode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Associate the metadata with the intro
|
||||||
|
intros.Add(
|
||||||
|
new IntroWithMetadata(
|
||||||
|
episode.SeriesName,
|
||||||
|
episode.AiredSeasonNumber ?? 0,
|
||||||
|
episode.Name,
|
||||||
|
intro.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return intros;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Net.Mime;
|
||||||
|
using System.Text;
|
||||||
|
using MediaBrowser.Common;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Troubleshooting controller.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = "RequiresElevation")]
|
||||||
|
[ApiController]
|
||||||
|
[Produces(MediaTypeNames.Application.Json)]
|
||||||
|
[Route("IntroSkipper")]
|
||||||
|
public class TroubleshootingController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IApplicationHost _applicationHost;
|
||||||
|
private readonly ILogger<TroubleshootingController> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TroubleshootingController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="applicationHost">Application host.</param>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public TroubleshootingController(
|
||||||
|
IApplicationHost applicationHost,
|
||||||
|
ILogger<TroubleshootingController> logger)
|
||||||
|
{
|
||||||
|
_applicationHost = applicationHost;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a Markdown formatted support bundle.
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200">Support bundle created.</response>
|
||||||
|
/// <returns>Support bundle.</returns>
|
||||||
|
[HttpGet("SupportBundle")]
|
||||||
|
[Produces(MediaTypeNames.Text.Plain)]
|
||||||
|
public ActionResult<string> GetSupportBundle()
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance!.Configuration;
|
||||||
|
var bundle = new StringBuilder();
|
||||||
|
|
||||||
|
bundle.Append("* Jellyfin version: ");
|
||||||
|
bundle.Append(_applicationHost.ApplicationVersionString);
|
||||||
|
bundle.Append('\n');
|
||||||
|
|
||||||
|
var version = Plugin.Instance!.Version.ToString(3);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var commit = Plugin.Instance!.GetCommit();
|
||||||
|
if (!string.IsNullOrWhiteSpace(commit))
|
||||||
|
{
|
||||||
|
version += string.Concat("+", commit.AsSpan(0, 12));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unable to append commit to version: {Exception}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle.Append("* Plugin version: ");
|
||||||
|
bundle.Append(version);
|
||||||
|
bundle.Append('\n');
|
||||||
|
|
||||||
|
bundle.Append("* Queue contents: ");
|
||||||
|
bundle.Append(Plugin.Instance!.TotalQueued);
|
||||||
|
bundle.Append(" episodes, ");
|
||||||
|
bundle.Append(Plugin.Instance!.TotalSeasons);
|
||||||
|
bundle.Append(" seasons\n");
|
||||||
|
|
||||||
|
bundle.Append("* Warnings: `");
|
||||||
|
bundle.Append(WarningManager.GetWarnings());
|
||||||
|
bundle.Append("`\n");
|
||||||
|
|
||||||
|
bundle.Append(FFmpegWrapper.GetChromaprintLogs());
|
||||||
|
|
||||||
|
return bundle.ToString();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,210 @@
|
|||||||
|
// 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.Net.Mime;
|
||||||
|
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = "RequiresElevation")]
|
||||||
|
[ApiController]
|
||||||
|
[Produces(MediaTypeNames.Application.Json)]
|
||||||
|
[Route("Intros")]
|
||||||
|
public class VisualizationController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<VisualizationController> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="VisualizationController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public VisualizationController(ILogger<VisualizationController> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all show names and seasons.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Dictionary of show names to a list of season names.</returns>
|
||||||
|
[HttpGet("Shows")]
|
||||||
|
public ActionResult<Dictionary<string, HashSet<string>>> GetShowSeasons()
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Returning season names by series");
|
||||||
|
|
||||||
|
var showSeasons = new Dictionary<string, HashSet<string>>();
|
||||||
|
|
||||||
|
// Loop through all seasons in the analysis queue
|
||||||
|
foreach (var kvp in Plugin.Instance!.QueuedMediaItems)
|
||||||
|
{
|
||||||
|
// Check that this season contains at least one episode.
|
||||||
|
var episodes = kvp.Value;
|
||||||
|
if (episodes is null || episodes.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping season {Id} (null or empty)", kvp.Key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peek at the top episode from this season and store the series name and season number.
|
||||||
|
var first = episodes[0];
|
||||||
|
var series = first.SeriesName;
|
||||||
|
var season = GetSeasonName(first);
|
||||||
|
|
||||||
|
// Validate the series and season before attempting to store it.
|
||||||
|
if (string.IsNullOrWhiteSpace(series) || string.IsNullOrWhiteSpace(season))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping season {Id} (no name or number)", kvp.Key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryAdd is used when adding the HashSet since it is a no-op if one was already created for this series.
|
||||||
|
showSeasons.TryAdd(series, new HashSet<string>());
|
||||||
|
showSeasons[series].Add(season);
|
||||||
|
}
|
||||||
|
|
||||||
|
return showSeasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the names and unique identifiers of all episodes in the provided season.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="series">Show name.</param>
|
||||||
|
/// <param name="season">Season name.</param>
|
||||||
|
/// <returns>List of episode titles.</returns>
|
||||||
|
[HttpGet("Show/{Series}/{Season}")]
|
||||||
|
public ActionResult<List<EpisodeVisualization>> GetSeasonEpisodes(
|
||||||
|
[FromRoute] string series,
|
||||||
|
[FromRoute] string season)
|
||||||
|
{
|
||||||
|
var visualEpisodes = new List<EpisodeVisualization>();
|
||||||
|
|
||||||
|
if (!LookupSeasonByName(series, season, out var episodes))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var e in episodes)
|
||||||
|
{
|
||||||
|
visualEpisodes.Add(new EpisodeVisualization(e.EpisodeId, e.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return visualEpisodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fingerprint the provided episode and returns the uncompressed fingerprint data points.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Episode id.</param>
|
||||||
|
/// <returns>Read only collection of fingerprint points.</returns>
|
||||||
|
[HttpGet("Episode/{Id}/Chromaprint")]
|
||||||
|
public ActionResult<uint[]> GetEpisodeFingerprint([FromRoute] Guid id)
|
||||||
|
{
|
||||||
|
// Search through all queued episodes to find the requested id
|
||||||
|
foreach (var season in Plugin.Instance!.QueuedMediaItems)
|
||||||
|
{
|
||||||
|
foreach (var needle in season.Value)
|
||||||
|
{
|
||||||
|
if (needle.EpisodeId == id)
|
||||||
|
{
|
||||||
|
return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erases all timestamps for the provided season.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="series">Show name.</param>
|
||||||
|
/// <param name="season">Season name.</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/{Series}/{Season}")]
|
||||||
|
public ActionResult EraseSeason([FromRoute] string series, [FromRoute] string season)
|
||||||
|
{
|
||||||
|
if (!LookupSeasonByName(series, season, out var episodes))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Erasing timestamps for {Series} {Season} at user request", series, season);
|
||||||
|
|
||||||
|
foreach (var e in episodes)
|
||||||
|
{
|
||||||
|
Plugin.Instance!.Intros.Remove(e.EpisodeId);
|
||||||
|
Plugin.Instance!.Credits.Remove(e.EpisodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugin.Instance!.SaveTimestamps();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the introduction timestamps for the provided episode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Episode ID to update timestamps for.</param>
|
||||||
|
/// <param name="timestamps">New introduction start and end times.</param>
|
||||||
|
/// <response code="204">New introduction timestamps saved.</response>
|
||||||
|
/// <returns>No content.</returns>
|
||||||
|
[HttpPost("Episode/{Id}/UpdateIntroTimestamps")]
|
||||||
|
[Obsolete("deprecated use Episode/{Id}/Timestamps")]
|
||||||
|
public ActionResult UpdateIntroTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps)
|
||||||
|
{
|
||||||
|
if (timestamps.IntroEnd > 0.0)
|
||||||
|
{
|
||||||
|
var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd);
|
||||||
|
Plugin.Instance!.Intros[id] = new Intro(id, tr);
|
||||||
|
Plugin.Instance.SaveTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetSeasonName(QueuedEpisode episode)
|
||||||
|
{
|
||||||
|
return "Season " + episode.SeasonNumber.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lookup a named season of a series and return all queued episodes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="series">Series name.</param>
|
||||||
|
/// <param name="season">Season name.</param>
|
||||||
|
/// <param name="episodes">Episodes.</param>
|
||||||
|
/// <returns>Boolean indicating if the requested season was found.</returns>
|
||||||
|
private bool LookupSeasonByName(string series, string season, out List<QueuedEpisode> episodes)
|
||||||
|
{
|
||||||
|
foreach (var queuedEpisodes in Plugin.Instance!.QueuedMediaItems)
|
||||||
|
{
|
||||||
|
var first = queuedEpisodes.Value[0];
|
||||||
|
var firstSeasonName = GetSeasonName(first);
|
||||||
|
|
||||||
|
// Assert that the queued episode series and season are equal to what was requested
|
||||||
|
if (
|
||||||
|
!string.Equals(first.SeriesName, series, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!string.Equals(firstSeasonName, season, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes = queuedEpisodes.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes = new List<QueuedEpisode>();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace IntroSkipper.Data;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Type of media file analysis to perform.
|
/// Type of media file analysis to perform.
|
||||||
@ -17,14 +17,4 @@ public enum AnalysisMode
|
|||||||
/// Detect credits.
|
/// Detect credits.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Credits,
|
Credits,
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Detect previews.
|
|
||||||
/// </summary>
|
|
||||||
Preview,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Detect recaps.
|
|
||||||
/// </summary>
|
|
||||||
Recap,
|
|
||||||
}
|
}
|
31
ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs
Normal file
31
ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A frame of video that partially (or entirely) consists of black pixels.
|
||||||
|
/// </summary>
|
||||||
|
public class BlackFrame
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="BlackFrame"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="percent">Percentage of the frame that is black.</param>
|
||||||
|
/// <param name="time">Time this frame appears at.</param>
|
||||||
|
public BlackFrame(int percent, double time)
|
||||||
|
{
|
||||||
|
Percentage = percent;
|
||||||
|
Time = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the percentage of the frame that is black.
|
||||||
|
/// </summary>
|
||||||
|
public int Percentage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the time (in seconds) this frame appeared at.
|
||||||
|
/// </summary>
|
||||||
|
public double Time { get; set; }
|
||||||
|
}
|
45
ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs
Normal file
45
ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
/// <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,
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Episode name and internal ID as returned by the visualization controller.
|
||||||
|
/// </summary>
|
||||||
|
public class EpisodeVisualization
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="EpisodeVisualization"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Episode id.</param>
|
||||||
|
/// <param name="name">Episode name.</param>
|
||||||
|
public EpisodeVisualization(Guid id, string name)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; private set; } = string.Empty;
|
||||||
|
}
|
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace IntroSkipper.Data;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception raised when an error is encountered analyzing audio.
|
/// Exception raised when an error is encountered analyzing audio.
|
149
ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs
Normal file
149
ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of fingerprinting and analyzing two episodes in a season.
|
||||||
|
/// All times are measured in seconds relative to the beginning of the media file.
|
||||||
|
/// </summary>
|
||||||
|
public class Intro
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Intro"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="episode">Episode.</param>
|
||||||
|
/// <param name="intro">Introduction time range.</param>
|
||||||
|
public Intro(Guid episode, TimeRange intro)
|
||||||
|
{
|
||||||
|
EpisodeId = episode;
|
||||||
|
IntroStart = intro.Start;
|
||||||
|
IntroEnd = intro.End;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Intro"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="episode">Episode.</param>
|
||||||
|
public Intro(Guid episode)
|
||||||
|
{
|
||||||
|
EpisodeId = episode;
|
||||||
|
IntroStart = 0;
|
||||||
|
IntroEnd = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Intro"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="intro">intro.</param>
|
||||||
|
public Intro(Intro intro)
|
||||||
|
{
|
||||||
|
EpisodeId = intro.EpisodeId;
|
||||||
|
IntroStart = intro.IntroStart;
|
||||||
|
IntroEnd = intro.IntroEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Intro"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public Intro()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Episode ID.
|
||||||
|
/// </summary>
|
||||||
|
public Guid EpisodeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether this introduction is valid or not.
|
||||||
|
/// Invalid results must not be returned through the API.
|
||||||
|
/// </summary>
|
||||||
|
public bool Valid => IntroEnd > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the duration of this intro.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public double Duration => IntroEnd - IntroStart;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the introduction sequence start time.
|
||||||
|
/// </summary>
|
||||||
|
public double IntroStart { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the introduction sequence end time.
|
||||||
|
/// </summary>
|
||||||
|
public double IntroEnd { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the recommended time to display the skip intro prompt.
|
||||||
|
/// </summary>
|
||||||
|
public double ShowSkipPromptAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the recommended time to hide the skip intro prompt.
|
||||||
|
/// </summary>
|
||||||
|
public double HideSkipPromptAt { get; set; }
|
||||||
|
|
||||||
|
/// <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(IntroStart, 2);
|
||||||
|
var end = Math.Round(IntroEnd, 2);
|
||||||
|
|
||||||
|
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0} {1} {2}", start, end, (int)action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An Intro class with episode metadata. Only used in end to end testing programs.
|
||||||
|
/// </summary>
|
||||||
|
public class IntroWithMetadata : Intro
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="IntroWithMetadata"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="series">Series name.</param>
|
||||||
|
/// <param name="season">Season number.</param>
|
||||||
|
/// <param name="title">Episode title.</param>
|
||||||
|
/// <param name="intro">Intro timestamps.</param>
|
||||||
|
public IntroWithMetadata(string series, int season, string title, Intro intro)
|
||||||
|
{
|
||||||
|
Series = series;
|
||||||
|
Season = season;
|
||||||
|
Title = title;
|
||||||
|
|
||||||
|
EpisodeId = intro.EpisodeId;
|
||||||
|
IntroStart = intro.IntroStart;
|
||||||
|
IntroEnd = intro.IntroEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the series name of the TV episode associated with this intro.
|
||||||
|
/// </summary>
|
||||||
|
public string Series { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the season number of the TV episode associated with this intro.
|
||||||
|
/// </summary>
|
||||||
|
public int Season { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the title of the TV episode associated with this intro.
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; }
|
||||||
|
}
|
@ -1,9 +1,9 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
namespace IntroSkipper.Data;
|
using System;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Support bundle warning.
|
/// Support bundle warning.
|
||||||
@ -31,3 +31,37 @@ public enum PluginWarning
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
IncompatibleFFmpegBuild = 4,
|
IncompatibleFFmpegBuild = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warning manager.
|
||||||
|
/// </summary>
|
||||||
|
public static class WarningManager
|
||||||
|
{
|
||||||
|
private static PluginWarning warnings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set warning.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="warning">Warning.</param>
|
||||||
|
public static void SetFlag(PluginWarning warning)
|
||||||
|
{
|
||||||
|
warnings |= warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear warnings.
|
||||||
|
/// </summary>
|
||||||
|
public static void Clear()
|
||||||
|
{
|
||||||
|
warnings = PluginWarning.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get warnings.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Warnings.</returns>
|
||||||
|
public static string GetWarnings()
|
||||||
|
{
|
||||||
|
return warnings.ToString();
|
||||||
|
}
|
||||||
|
}
|
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace IntroSkipper.Data;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Episode queued for analysis.
|
/// Episode queued for analysis.
|
||||||
@ -25,16 +25,6 @@ public class QueuedEpisode
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid EpisodeId { get; set; }
|
public Guid EpisodeId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the season id.
|
|
||||||
/// </summary>
|
|
||||||
public Guid SeasonId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the series id.
|
|
||||||
/// </summary>
|
|
||||||
public Guid SeriesId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the full path to episode.
|
/// Gets or sets the full path to episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -45,21 +35,6 @@ public class QueuedEpisode
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether an episode is Anime.
|
|
||||||
/// </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>
|
/// <summary>
|
||||||
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
|
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
|
||||||
/// </summary>
|
/// </summary>
|
@ -2,8 +2,9 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace IntroSkipper.Data;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
#pragma warning disable CA1036 // Override methods on comparable types
|
#pragma warning disable CA1036 // Override methods on comparable types
|
||||||
|
|
||||||
@ -64,7 +65,7 @@ public class TimeRange : IComparable
|
|||||||
/// <returns>int.</returns>
|
/// <returns>int.</returns>
|
||||||
public int CompareTo(object? obj)
|
public int CompareTo(object? obj)
|
||||||
{
|
{
|
||||||
if (obj is not TimeRange tr)
|
if (!(obj is TimeRange tr))
|
||||||
{
|
{
|
||||||
throw new ArgumentException("obj must be a TimeRange");
|
throw new ArgumentException("obj must be a TimeRange");
|
||||||
}
|
}
|
||||||
@ -84,3 +85,51 @@ public class TimeRange : IComparable
|
|||||||
(Start < tr.End && tr.End < End);
|
(Start < tr.End && tr.End < End);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pragma warning restore CA1036
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time range helpers.
|
||||||
|
/// </summary>
|
||||||
|
public static class TimeRangeHelpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the longest contiguous time range.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="times">Sorted timestamps to search.</param>
|
||||||
|
/// <param name="maximumDistance">Maximum distance permitted between contiguous timestamps.</param>
|
||||||
|
/// <returns>The longest contiguous time range (if one was found), or null (if none was found).</returns>
|
||||||
|
public static TimeRange? FindContiguous(double[] times, double maximumDistance)
|
||||||
|
{
|
||||||
|
if (times.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.Sort(times);
|
||||||
|
|
||||||
|
var ranges = new List<TimeRange>();
|
||||||
|
var currentRange = new TimeRange(times[0], times[0]);
|
||||||
|
|
||||||
|
// For all provided timestamps, check if it is contiguous with its neighbor.
|
||||||
|
for (var i = 0; i < times.Length - 1; i++)
|
||||||
|
{
|
||||||
|
var current = times[i];
|
||||||
|
var next = times[i + 1];
|
||||||
|
|
||||||
|
if (next - current <= maximumDistance)
|
||||||
|
{
|
||||||
|
currentRange.End = next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ranges.Add(new TimeRange(currentRange));
|
||||||
|
currentRange = new TimeRange(next, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and return the longest contiguous range.
|
||||||
|
ranges.Sort();
|
||||||
|
|
||||||
|
return (ranges.Count > 0) ? ranges[0] : null;
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace IntroSkipper.Data
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result of fingerprinting and analyzing two episodes in a season.
|
/// Result of fingerprinting and analyzing two episodes in a season.
|
||||||
@ -12,21 +12,11 @@ namespace IntroSkipper.Data
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets Introduction.
|
/// Gets or sets Introduction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Segment Introduction { get; set; } = new Segment();
|
public Intro Introduction { get; set; } = new Intro();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets Credits.
|
/// Gets or sets Credits.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Segment Credits { get; set; } = new Segment();
|
public Intro Credits { get; set; } = new Intro();
|
||||||
|
|
||||||
/// <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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
125
ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs
Normal file
125
ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <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(ReadOnlyCollection<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");
|
||||||
|
}
|
||||||
|
}
|
357
ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs
Normal file
357
ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server entrypoint.
|
||||||
|
/// </summary>
|
||||||
|
public class Entrypoint : IServerEntryPoint
|
||||||
|
{
|
||||||
|
private readonly IUserManager _userManager;
|
||||||
|
private readonly IUserViewManager _userViewManager;
|
||||||
|
private readonly ITaskManager _taskManager;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly ILogger<Entrypoint> _logger;
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
private Timer _queueTimer;
|
||||||
|
private bool _analyzeAgain;
|
||||||
|
private static CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private static ManualResetEventSlim _autoTaskCompletEvent = new ManualResetEventSlim(false);
|
||||||
|
private QueueManager _queueManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Entrypoint"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userManager">User manager.</param>
|
||||||
|
/// <param name="userViewManager">User view manager.</param>
|
||||||
|
/// <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(
|
||||||
|
IUserManager userManager,
|
||||||
|
IUserViewManager userViewManager,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
ITaskManager taskManager,
|
||||||
|
ILogger<Entrypoint> logger,
|
||||||
|
ILoggerFactory loggerFactory)
|
||||||
|
{
|
||||||
|
_userManager = userManager;
|
||||||
|
_userViewManager = userViewManager;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_taskManager = taskManager;
|
||||||
|
_logger = logger;
|
||||||
|
_loggerFactory = loggerFactory;
|
||||||
|
|
||||||
|
_queueTimer = new Timer(
|
||||||
|
OnTimerCallback,
|
||||||
|
null,
|
||||||
|
Timeout.InfiniteTimeSpan,
|
||||||
|
Timeout.InfiniteTimeSpan);
|
||||||
|
|
||||||
|
_queueManager = new QueueManager(
|
||||||
|
_loggerFactory.CreateLogger<QueueManager>(),
|
||||||
|
_libraryManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers event handler.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Task.</returns>
|
||||||
|
public Task RunAsync()
|
||||||
|
{
|
||||||
|
_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");
|
||||||
|
_queueManager.GetMediaItems();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError("Unable to run startup enqueue: {Exception}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance!.Configuration.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Entrypoint.AutomaticTaskState == TaskState.Running)
|
||||||
|
{
|
||||||
|
_queueManager.QueueEpisode(episode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Plugin.Instance!.Configuration.PathRestrictions.Add(itemChangeEventArgs.Item.ContainingFolderPath);
|
||||||
|
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 (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance!.Configuration.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Entrypoint.AutomaticTaskState == TaskState.Running)
|
||||||
|
{
|
||||||
|
_queueManager.QueueEpisode(episode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Plugin.Instance!.Configuration.PathRestrictions.Add(itemChangeEventArgs.Item.ContainingFolderPath);
|
||||||
|
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 (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance!.Configuration.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; // Items added during a scan will be included later.
|
||||||
|
}
|
||||||
|
else if (ScheduledTaskSemaphore.CurrentCount > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wait for timer to be completed.
|
||||||
|
/// </summary>
|
||||||
|
private void PerformAnalysis()
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Timer elapsed - start analyzing");
|
||||||
|
_autoTaskCompletEvent.Reset();
|
||||||
|
|
||||||
|
using (_cancellationTokenSource = new CancellationTokenSource())
|
||||||
|
{
|
||||||
|
var progress = new Progress<double>();
|
||||||
|
var cancellationToken = _cancellationTokenSource.Token;
|
||||||
|
|
||||||
|
var modes = new List<AnalysisMode>();
|
||||||
|
var tasklogger = _loggerFactory.CreateLogger("DefaultLogger");
|
||||||
|
|
||||||
|
if (Plugin.Instance!.Configuration.AutoDetectIntros && Plugin.Instance!.Configuration.AutoDetectCredits)
|
||||||
|
{
|
||||||
|
modes.Add(AnalysisMode.Introduction);
|
||||||
|
modes.Add(AnalysisMode.Credits);
|
||||||
|
tasklogger = _loggerFactory.CreateLogger<DetectIntrosCreditsTask>();
|
||||||
|
}
|
||||||
|
else if (Plugin.Instance!.Configuration.AutoDetectIntros)
|
||||||
|
{
|
||||||
|
modes.Add(AnalysisMode.Introduction);
|
||||||
|
tasklogger = _loggerFactory.CreateLogger<DetectIntrosTask>();
|
||||||
|
}
|
||||||
|
else if (Plugin.Instance!.Configuration.AutoDetectCredits)
|
||||||
|
{
|
||||||
|
modes.Add(AnalysisMode.Credits);
|
||||||
|
tasklogger = _loggerFactory.CreateLogger<DetectCreditsTask>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
|
||||||
|
modes.AsReadOnly(),
|
||||||
|
tasklogger,
|
||||||
|
_loggerFactory,
|
||||||
|
_libraryManager);
|
||||||
|
|
||||||
|
baseCreditAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugin.Instance!.Configuration.PathRestrictions.Clear();
|
||||||
|
_autoTaskCompletEvent.Set();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
|
||||||
|
// New item detected, start timer again
|
||||||
|
if (_analyzeAgain)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Analyzing ended, but we need to analyze again!");
|
||||||
|
_analyzeAgain = false;
|
||||||
|
StartTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Method to cancel the automatic task.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
public static void CancelAutomaticTask(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_cancellationTokenSource != null)
|
||||||
|
{
|
||||||
|
if (!_cancellationTokenSource.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_cancellationTokenSource.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
_autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispose.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Protected dispose.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dispose">Dispose.</param>
|
||||||
|
protected virtual void Dispose(bool dispose)
|
||||||
|
{
|
||||||
|
if (!dispose)
|
||||||
|
{
|
||||||
|
Plugin.Instance!.Configuration.PathRestrictions.Clear();
|
||||||
|
_libraryManager.ItemAdded -= OnItemAdded;
|
||||||
|
_libraryManager.ItemUpdated -= OnItemModified;
|
||||||
|
_taskManager.TaskCompleted -= OnLibraryRefresh;
|
||||||
|
|
||||||
|
if (_cancellationTokenSource != null) // Null Check
|
||||||
|
{
|
||||||
|
_cancellationTokenSource.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_queueTimer.Dispose();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -9,32 +9,34 @@ using System.Globalization;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using IntroSkipper.Data;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wrapper for libchromaprint and the silencedetect filter.
|
/// Wrapper for libchromaprint and the silencedetect filter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static partial class FFmpegWrapper
|
public static class FFmpegWrapper
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
|
/// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly Regex _silenceDetectionExpression = SilenceRegex();
|
private static readonly Regex SilenceDetectionExpression = new(
|
||||||
|
"silence_(?<type>start|end): (?<time>[0-9\\.]+)");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Used with FFmpeg's blackframe filter to extract the time and percentage of black pixels.
|
/// Used with FFmpeg's blackframe filter to extract the time and percentage of black pixels.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly Regex _blackFrameRegex = BlackFrameRegex();
|
private static readonly Regex BlackFrameRegex = new("(pblack|t):[0-9.]+");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the logger.
|
/// Gets or sets the logger.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ILogger? Logger { get; set; }
|
public static ILogger? Logger { get; set; }
|
||||||
|
|
||||||
private static Dictionary<string, string> ChromaprintLogs { get; set; } = [];
|
private static Dictionary<string, string> ChromaprintLogs { get; set; } = new();
|
||||||
|
|
||||||
|
private static ConcurrentDictionary<AnalysisMode, ConcurrentDictionary<Guid, Dictionary<uint, int>>> InvertedIndexCache { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Check that the installed version of ffmpeg supports chromaprint.
|
/// Check that the installed version of ffmpeg supports chromaprint.
|
||||||
@ -126,44 +128,70 @@ public static partial class FFmpegWrapper
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
throw new ArgumentException("Unknown analysis mode " + mode);
|
throw new ArgumentException("Unknown analysis mode " + mode.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
return Fingerprint(episode, mode, start, end);
|
return Fingerprint(episode, mode, start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Episode ID.</param>
|
||||||
|
/// <param name="fingerprint">Chromaprint fingerprint.</param>
|
||||||
|
/// <param name="mode">Mode.</param>
|
||||||
|
/// <returns>Inverted index.</returns>
|
||||||
|
public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint, AnalysisMode mode)
|
||||||
|
{
|
||||||
|
var innerDictionary = InvertedIndexCache.GetOrAdd(mode, _ => new ConcurrentDictionary<Guid, Dictionary<uint, int>>());
|
||||||
|
|
||||||
|
// Check if cached for the ID
|
||||||
|
if (innerDictionary.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
innerDictionary[id] = invIndex;
|
||||||
|
|
||||||
|
return invIndex;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Detect ranges of silence in the provided episode.
|
/// Detect ranges of silence in the provided episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="episode">Queued episode.</param>
|
/// <param name="episode">Queued episode.</param>
|
||||||
/// <param name="range">Time range to search.</param>
|
/// <param name="limit">Maximum amount of audio (in seconds) to detect silence in.</param>
|
||||||
/// <returns>Array of TimeRange objects that are silent in the queued episode.</returns>
|
/// <returns>Array of TimeRange objects that are silent in the queued episode.</returns>
|
||||||
public static TimeRange[] DetectSilence(QueuedEpisode episode, TimeRange range)
|
public static TimeRange[] DetectSilence(QueuedEpisode episode, int limit)
|
||||||
{
|
{
|
||||||
Logger?.LogTrace(
|
Logger?.LogTrace(
|
||||||
"Detecting silence in \"{File}\" (range {Start}-{End}, id {Id})",
|
"Detecting silence in \"{File}\" (limit {Limit}, id {Id})",
|
||||||
episode.Path,
|
episode.Path,
|
||||||
range.Start,
|
limit,
|
||||||
range.End,
|
|
||||||
episode.EpisodeId);
|
episode.EpisodeId);
|
||||||
|
|
||||||
// -vn, -sn, -dn: ignore video, subtitle, and data tracks
|
// -vn, -sn, -dn: ignore video, subtitle, and data tracks
|
||||||
var args = string.Format(
|
var args = string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
"-vn -sn -dn " +
|
"-vn -sn -dn " +
|
||||||
"-ss {0} -i \"{1}\" -to {2} -af \"silencedetect=noise={3}dB:duration=0.1\" -f null -",
|
"-i \"{0}\" -to {1} -af \"silencedetect=noise={2}dB:duration=0.1\" -f null -",
|
||||||
range.Start,
|
|
||||||
episode.Path,
|
episode.Path,
|
||||||
range.End - range.Start,
|
limit,
|
||||||
Plugin.Instance?.Configuration.SilenceDetectionMaximumNoise ?? -50);
|
Plugin.Instance?.Configuration.SilenceDetectionMaximumNoise ?? -50);
|
||||||
|
|
||||||
// Cache the output of this command to "GUID-intro-silence-v2"
|
// Cache the output of this command to "GUID-intro-silence-v1"
|
||||||
var cacheKey = string.Format(
|
var cacheKey = episode.EpisodeId.ToString("N") + "-intro-silence-v1";
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
"{0}-silence-{1}-{2}-v2",
|
|
||||||
episode.EpisodeId.ToString("N"),
|
|
||||||
range.Start,
|
|
||||||
range.End);
|
|
||||||
|
|
||||||
var currentRange = new TimeRange();
|
var currentRange = new TimeRange();
|
||||||
var silenceRanges = new List<TimeRange>();
|
var silenceRanges = new List<TimeRange>();
|
||||||
@ -175,18 +203,18 @@ public static partial class FFmpegWrapper
|
|||||||
* [silencedetect @ 0x000000000000] silence_end: 56.123 | silence_duration: 43.783
|
* [silencedetect @ 0x000000000000] silence_end: 56.123 | silence_duration: 43.783
|
||||||
*/
|
*/
|
||||||
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
|
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
|
||||||
foreach (Match match in _silenceDetectionExpression.Matches(raw))
|
foreach (Match match in SilenceDetectionExpression.Matches(raw))
|
||||||
{
|
{
|
||||||
var isStart = match.Groups["type"].Value == "start";
|
var isStart = match.Groups["type"].Value == "start";
|
||||||
var time = Convert.ToDouble(match.Groups["time"].Value, CultureInfo.InvariantCulture);
|
var time = Convert.ToDouble(match.Groups["time"].Value, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
if (isStart)
|
if (isStart)
|
||||||
{
|
{
|
||||||
currentRange.Start = time + range.Start;
|
currentRange.Start = time;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
currentRange.End = time + range.Start;
|
currentRange.End = time;
|
||||||
silenceRanges.Add(new TimeRange(currentRange));
|
silenceRanges.Add(new TimeRange(currentRange));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -237,7 +265,7 @@ public static partial class FFmpegWrapper
|
|||||||
// In our case, the metadata contained something that matched the regex.
|
// In our case, the metadata contained something that matched the regex.
|
||||||
if (line.StartsWith("[Parsed_blackframe_", StringComparison.OrdinalIgnoreCase))
|
if (line.StartsWith("[Parsed_blackframe_", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var matches = _blackFrameRegex.Matches(line);
|
var matches = BlackFrameRegex.Matches(line);
|
||||||
if (matches.Count != 2)
|
if (matches.Count != 2)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@ -392,7 +420,8 @@ public static partial class FFmpegWrapper
|
|||||||
RedirectStandardError = stderr
|
RedirectStandardError = stderr
|
||||||
};
|
};
|
||||||
|
|
||||||
using var ffmpeg = new Process { StartInfo = info };
|
using (var ffmpeg = new Process { StartInfo = info })
|
||||||
|
{
|
||||||
Logger?.LogDebug("Starting ffmpeg with the following arguments: {Arguments}", ffmpeg.StartInfo.Arguments);
|
Logger?.LogDebug("Starting ffmpeg with the following arguments: {Arguments}", ffmpeg.StartInfo.Arguments);
|
||||||
|
|
||||||
ffmpeg.Start();
|
ffmpeg.Start();
|
||||||
@ -406,7 +435,8 @@ public static partial class FFmpegWrapper
|
|||||||
Logger?.LogDebug("ffmpeg priority could not be modified. {Message}", e.Message);
|
Logger?.LogDebug("ffmpeg priority could not be modified. {Message}", e.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
using var ms = new MemoryStream();
|
using (var ms = new MemoryStream())
|
||||||
|
{
|
||||||
var buf = new byte[4096];
|
var buf = new byte[4096];
|
||||||
int bytesRead;
|
int bytesRead;
|
||||||
|
|
||||||
@ -430,6 +460,8 @@ public static partial class FFmpegWrapper
|
|||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fingerprint a queued episode.
|
/// Fingerprint a queued episode.
|
||||||
@ -575,44 +607,20 @@ public static partial class FFmpegWrapper
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Remove a cached episode fingerprint from disk.
|
/// Remove a cached episode fingerprint from disk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">Episode to remove from cache.</param>
|
/// <param name="episodeId">Episode to remove from cache.</param>
|
||||||
public static void DeleteEpisodeCache(Guid id)
|
/// <param name="mode">Analysis mode.</param>
|
||||||
|
public static void DeleteEpisodeCache(string episodeId, AnalysisMode mode)
|
||||||
{
|
{
|
||||||
var cachePath = Path.Join(
|
var cachePath = Path.Join(
|
||||||
Plugin.Instance!.FingerprintCachePath,
|
Plugin.Instance!.FingerprintCachePath,
|
||||||
id.ToString("N"));
|
episodeId);
|
||||||
|
|
||||||
// File.Delete(cachePath);
|
if (mode == AnalysisMode.Credits)
|
||||||
// File.Delete(cachePath + "-intro-silence-v1");
|
|
||||||
// File.Delete(cachePath + "-credits");
|
|
||||||
|
|
||||||
var filePattern = Path.GetFileName(cachePath) + "*";
|
|
||||||
foreach (var filePath in Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath, filePattern))
|
|
||||||
{
|
{
|
||||||
Logger?.LogDebug("DeleteEpisodeCache {FilePath}", filePath);
|
cachePath += "-credits";
|
||||||
File.Delete(filePath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
File.Delete(cachePath);
|
||||||
/// Remove cached fingerprints from disk by mode.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mode">Analysis mode.</param>
|
|
||||||
public static void DeleteCacheFiles(AnalysisMode mode)
|
|
||||||
{
|
|
||||||
foreach (var filePath in Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath))
|
|
||||||
{
|
|
||||||
var shouldDelete = (mode == AnalysisMode.Introduction)
|
|
||||||
? !filePath.Contains("credit", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& !filePath.Contains("blackframes", StringComparison.OrdinalIgnoreCase)
|
|
||||||
: filePath.Contains("credit", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| filePath.Contains("blackframes", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (shouldDelete)
|
|
||||||
{
|
|
||||||
File.Delete(filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -621,8 +629,7 @@ public static partial class FFmpegWrapper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="episode">Episode.</param>
|
/// <param name="episode">Episode.</param>
|
||||||
/// <param name="mode">Analysis mode.</param>
|
/// <param name="mode">Analysis mode.</param>
|
||||||
/// <returns>Path.</returns>
|
private static string GetFingerprintCachePath(QueuedEpisode episode, AnalysisMode mode)
|
||||||
public static string GetFingerprintCachePath(QueuedEpisode episode, AnalysisMode mode)
|
|
||||||
{
|
{
|
||||||
var basePath = Path.Join(
|
var basePath = Path.Join(
|
||||||
Plugin.Instance!.FingerprintCachePath,
|
Plugin.Instance!.FingerprintCachePath,
|
||||||
@ -632,13 +639,14 @@ public static partial class FFmpegWrapper
|
|||||||
{
|
{
|
||||||
return basePath;
|
return basePath;
|
||||||
}
|
}
|
||||||
|
else if (mode == AnalysisMode.Credits)
|
||||||
if (mode == AnalysisMode.Credits)
|
|
||||||
{
|
{
|
||||||
return basePath + "-credits";
|
return basePath + "-credits";
|
||||||
}
|
}
|
||||||
|
else
|
||||||
throw new ArgumentException("Unknown analysis mode " + mode);
|
{
|
||||||
|
throw new ArgumentException("Unknown analysis mode " + mode.ToString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatFFmpegLog(string key)
|
private static string FormatFFmpegLog(string key)
|
||||||
@ -663,10 +671,4 @@ public static partial class FFmpegWrapper
|
|||||||
|
|
||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
[GeneratedRegex("silence_(?<type>start|end): (?<time>[0-9\\.]+)")]
|
|
||||||
private static partial Regex SilenceRegex();
|
|
||||||
|
|
||||||
[GeneratedRegex("(pblack|t):[0-9.]+")]
|
|
||||||
private static partial Regex BlackFrameRegex();
|
|
||||||
}
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.WarningManager")]
|
||||||
|
[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.IntroWithMetadata")]
|
||||||
|
[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.TimeRangeHelpers")]
|
483
ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs
Normal file
483
ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs
Normal file
@ -0,0 +1,483 @@
|
|||||||
|
// 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.Text.RegularExpressions;
|
||||||
|
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||||
|
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 MediaBrowser.Model.Updates;
|
||||||
|
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 IXmlSerializer _xmlSerializer;
|
||||||
|
private ILibraryManager _libraryManager;
|
||||||
|
private IItemRepository _itemRepository;
|
||||||
|
private ILogger<Plugin> _logger;
|
||||||
|
private string _introPath;
|
||||||
|
private string _creditsPath;
|
||||||
|
private string _oldintroPath;
|
||||||
|
private string _oldcreditsPath;
|
||||||
|
private string _oldFingerprintCachePath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <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(
|
||||||
|
IApplicationPaths applicationPaths,
|
||||||
|
IXmlSerializer xmlSerializer,
|
||||||
|
IServerConfigurationManager serverConfiguration,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IItemRepository itemRepository,
|
||||||
|
ILogger<Plugin> logger)
|
||||||
|
: base(applicationPaths, xmlSerializer)
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
|
||||||
|
_xmlSerializer = xmlSerializer;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_itemRepository = itemRepository;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
var cacheRoot = applicationPaths.CachePath;
|
||||||
|
var oldintrosDirectory = Path.Join(cacheRoot, pluginDirName);
|
||||||
|
if (!Directory.Exists(oldintrosDirectory))
|
||||||
|
{
|
||||||
|
pluginDirName = "intros";
|
||||||
|
pluginCachePath = "cache";
|
||||||
|
cacheRoot = applicationPaths.PluginConfigurationsPath;
|
||||||
|
oldintrosDirectory = Path.Join(cacheRoot, pluginDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
_oldFingerprintCachePath = Path.Join(oldintrosDirectory, pluginCachePath);
|
||||||
|
_oldintroPath = Path.Join(cacheRoot, pluginDirName, "intros.xml");
|
||||||
|
_oldcreditsPath = Path.Join(cacheRoot, pluginDirName, "credits.xml");
|
||||||
|
|
||||||
|
// Create the base & cache directories (if needed).
|
||||||
|
if (!Directory.Exists(FingerprintCachePath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(FingerprintCachePath);
|
||||||
|
|
||||||
|
// Check if the old cache directory exists
|
||||||
|
if (Directory.Exists(_oldFingerprintCachePath))
|
||||||
|
{
|
||||||
|
// move intro.xml if exists
|
||||||
|
if (File.Exists(_oldintroPath))
|
||||||
|
{
|
||||||
|
File.Move(_oldintroPath, _introPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// move credits.xml if exits
|
||||||
|
if (File.Exists(_oldcreditsPath))
|
||||||
|
{
|
||||||
|
File.Move(_oldcreditsPath, _creditsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the contents from old directory to new directory
|
||||||
|
string[] files = Directory.GetFiles(_oldFingerprintCachePath);
|
||||||
|
foreach (string file in files)
|
||||||
|
{
|
||||||
|
string fileName = Path.GetFileName(file);
|
||||||
|
string destFile = Path.Combine(FingerprintCachePath, fileName);
|
||||||
|
File.Move(file, destFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally, you may delete the old directory after moving its contents
|
||||||
|
Directory.Delete(oldintrosDirectory, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigurationChanged += OnConfigurationChanged;
|
||||||
|
|
||||||
|
MigrateRepoUrl(serverConfiguration);
|
||||||
|
|
||||||
|
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RestoreTimestamps();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject the skip intro button code into the web interface.
|
||||||
|
var indexPath = Path.Join(applicationPaths.WebPath, "index.html");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
InjectSkipButton(indexPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
|
||||||
|
|
||||||
|
if (ex is UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
var suggestion = OperatingSystem.IsLinux() ?
|
||||||
|
"running `sudo chown jellyfin PATH` (if this is a native installation)" :
|
||||||
|
"changing the permissions of PATH";
|
||||||
|
|
||||||
|
suggestion = suggestion.Replace("PATH", indexPath, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
_logger.LogError(
|
||||||
|
"Failed to add skip button to web interface. Try {Suggestion} and restarting the server. Error: {Error}",
|
||||||
|
suggestion,
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Unknown error encountered while adding skip button: {Error}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FFmpegWrapper.CheckFFmpegVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired after configuration has been saved so the auto skip timer can be stopped or started.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler? AutoSkipChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired after configuration has been saved so the auto skip timer can be stopped or started.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler? AutoSkipCreditsChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the results of fingerprinting all episodes.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<Guid, Intro> Intros { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all discovered ending credits.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<Guid, Intro> Credits { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the most recent media item queue.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<Guid, List<QueuedEpisode>> QueuedMediaItems { 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>
|
||||||
|
public void SaveTimestamps()
|
||||||
|
{
|
||||||
|
lock (_serializationLock)
|
||||||
|
{
|
||||||
|
var introList = new List<Intro>();
|
||||||
|
|
||||||
|
// Serialize intros
|
||||||
|
foreach (var intro in Instance!.Intros)
|
||||||
|
{
|
||||||
|
introList.Add(intro.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_xmlSerializer.SerializeToFile(introList, _introPath);
|
||||||
|
|
||||||
|
// Serialize credits
|
||||||
|
introList.Clear();
|
||||||
|
|
||||||
|
foreach (var intro in Instance!.Credits)
|
||||||
|
{
|
||||||
|
introList.Add(intro.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_xmlSerializer.SerializeToFile(introList, _creditsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 = (List<Intro>)_xmlSerializer.DeserializeFromFile(
|
||||||
|
typeof(List<Intro>),
|
||||||
|
_introPath);
|
||||||
|
|
||||||
|
foreach (var intro in introList)
|
||||||
|
{
|
||||||
|
Instance!.Intros[intro.EpisodeId] = intro;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(_creditsPath))
|
||||||
|
{
|
||||||
|
var creditList = (List<Intro>)_xmlSerializer.DeserializeFromFile(
|
||||||
|
typeof(List<Intro>),
|
||||||
|
_creditsPath);
|
||||||
|
|
||||||
|
foreach (var credit in creditList)
|
||||||
|
{
|
||||||
|
Instance!.Credits[credit.EpisodeId] = credit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<PluginPageInfo> GetPages()
|
||||||
|
{
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new PluginPageInfo
|
||||||
|
{
|
||||||
|
Name = this.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 commit used to build the plugin.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Commit.</returns>
|
||||||
|
public string GetCommit()
|
||||||
|
{
|
||||||
|
var commit = string.Empty;
|
||||||
|
|
||||||
|
var path = GetType().Namespace + ".Configuration.version.txt";
|
||||||
|
using var stream = GetType().Assembly.GetManifestResourceStream(path);
|
||||||
|
if (stream is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unable to read embedded version information");
|
||||||
|
return commit;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
commit = reader.ReadToEnd().TrimEnd();
|
||||||
|
|
||||||
|
if (commit == "unknown")
|
||||||
|
{
|
||||||
|
_logger.LogTrace("Embedded version information was not valid, ignoring");
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Unstable plugin version built from commit {Commit}", commit);
|
||||||
|
return commit;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal BaseItem? GetItem(Guid id)
|
||||||
|
{
|
||||||
|
return _libraryManager.GetItemById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 new List<ChapterInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _itemRepository.GetChapters(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void UpdateTimestamps(Dictionary<Guid, Intro> newTimestamps, AnalysisMode mode)
|
||||||
|
{
|
||||||
|
lock (_introsLock)
|
||||||
|
{
|
||||||
|
foreach (var intro in newTimestamps)
|
||||||
|
{
|
||||||
|
if (mode == AnalysisMode.Introduction)
|
||||||
|
{
|
||||||
|
Instance!.Intros[intro.Key] = intro.Value;
|
||||||
|
}
|
||||||
|
else if (mode == AnalysisMode.Credits)
|
||||||
|
{
|
||||||
|
Instance!.Credits[intro.Key] = intro.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Instance!.SaveTimestamps();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConfigurationChanged(object? sender, BasePluginConfiguration e)
|
||||||
|
{
|
||||||
|
AutoSkipChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
AutoSkipCreditsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MigrateRepoUrl(IServerConfigurationManager serverConfiguration)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<string> oldRepos = new List<string>
|
||||||
|
{
|
||||||
|
"https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/manifest.json",
|
||||||
|
"https://raw.githubusercontent.com/jumoog/intro-skipper/master/manifest.json",
|
||||||
|
"https://manifest.intro-skipper.workers.dev/manifest.json"
|
||||||
|
};
|
||||||
|
// Access the current server configuration
|
||||||
|
var config = serverConfiguration.Configuration;
|
||||||
|
|
||||||
|
// Get the list of current plugin repositories
|
||||||
|
var pluginRepositories = config.PluginRepositories?.ToList() ?? new List<RepositoryInfo>();
|
||||||
|
|
||||||
|
// check if old plugins exits
|
||||||
|
if (pluginRepositories.Exists(repo => repo != null && repo.Url != null && oldRepos.Contains(repo.Url)))
|
||||||
|
{
|
||||||
|
// remove all old plugins
|
||||||
|
pluginRepositories.RemoveAll(repo => repo != null && repo.Url != null && oldRepos.Contains(repo.Url));
|
||||||
|
|
||||||
|
// Add repository only if it does not exit
|
||||||
|
if (!pluginRepositories.Exists(repo => repo.Url == "https://manifest.intro-skipper.org/manifest.json"))
|
||||||
|
{
|
||||||
|
// Add the new repository to the list
|
||||||
|
pluginRepositories.Add(new RepositoryInfo
|
||||||
|
{
|
||||||
|
Name = "intro skipper (automatically migrated by plugin)",
|
||||||
|
Url = "https://manifest.intro-skipper.org/manifest.json",
|
||||||
|
Enabled = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the configuration with the new repository list
|
||||||
|
config.PluginRepositories = pluginRepositories.ToList();
|
||||||
|
|
||||||
|
// Save the updated configuration
|
||||||
|
serverConfiguration.SaveConfiguration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error occurred while migrating repo URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inject the skip button script into the web interface.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="indexPath">Full path to index.html.</param>
|
||||||
|
private void InjectSkipButton(string indexPath)
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
var contents = File.ReadAllText(indexPath);
|
||||||
|
|
||||||
|
var scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js\"></script>";
|
||||||
|
|
||||||
|
// Only inject the script tag once
|
||||||
|
if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Skip button already added");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
var headEnd = new Regex("</head>", RegexOptions.IgnoreCase);
|
||||||
|
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
|
||||||
|
|
||||||
|
// Write the modified file contents
|
||||||
|
File.WriteAllText(indexPath, contents);
|
||||||
|
|
||||||
|
_logger.LogInformation("Skip intro button successfully added");
|
||||||
|
}
|
||||||
|
}
|
302
ConfusedPolarBear.Plugin.IntroSkipper/QueueManager.cs
Normal file
302
ConfusedPolarBear.Plugin.IntroSkipper/QueueManager.cs
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages enqueuing library items for analysis.
|
||||||
|
/// </summary>
|
||||||
|
public class QueueManager
|
||||||
|
{
|
||||||
|
private ILibraryManager _libraryManager;
|
||||||
|
private ILogger<QueueManager> _logger;
|
||||||
|
|
||||||
|
private double analysisPercent;
|
||||||
|
private List<string> selectedLibraries;
|
||||||
|
private Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="QueueManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
/// <param name="libraryManager">Library manager.</param>
|
||||||
|
public QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryManager)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
|
||||||
|
selectedLibraries = new();
|
||||||
|
_queuedEpisodes = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all media items on the server.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Queued media items.</returns>
|
||||||
|
public ReadOnlyDictionary<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 (selectedLibraries.Count > 0 && !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);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var location in folder.Locations)
|
||||||
|
{
|
||||||
|
var item = _libraryManager.FindByPath(location, true);
|
||||||
|
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unable to find linked item at path {0}", location);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueLibraryContents(item.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(_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;
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// If any libraries have been selected for analysis, log their names.
|
||||||
|
if (selectedLibraries.Count > 0)
|
||||||
|
{
|
||||||
|
_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 = new[]
|
||||||
|
{
|
||||||
|
("SeriesSortName", SortOrder.Ascending),
|
||||||
|
("ParentIndexNumber", SortOrder.Ascending),
|
||||||
|
("IndexNumber", SortOrder.Ascending),
|
||||||
|
},
|
||||||
|
IncludeItemTypes = new BaseItemKind[] { 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Plugin.Instance!.Configuration.PathRestrictions.Count > 0)
|
||||||
|
{
|
||||||
|
if (!Plugin.Instance!.Configuration.PathRestrictions.Contains(item.ContainingFolderPath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueEpisode(episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Queued {Count} episodes", items.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a single episode to the current queue for analyzing.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="episode">The episode to analyze.</param>
|
||||||
|
public void QueueEpisode(Episode episode)
|
||||||
|
{
|
||||||
|
if (Plugin.Instance is null)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
_queuedEpisodes.TryAdd(episode.SeasonId, new List<QueuedEpisode>());
|
||||||
|
|
||||||
|
if (_queuedEpisodes[episode.SeasonId].Any(e => e.EpisodeId == episode.Id))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"\"{Name}\" from series \"{Series}\" ({Id}) is already queued",
|
||||||
|
episode.Name,
|
||||||
|
episode.SeriesName,
|
||||||
|
episode.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = duration;
|
||||||
|
|
||||||
|
if (fingerprintDuration >= 5 * 60)
|
||||||
|
{
|
||||||
|
fingerprintDuration *= analysisPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
fingerprintDuration = Math.Min(
|
||||||
|
fingerprintDuration,
|
||||||
|
60 * Plugin.Instance!.Configuration.AnalysisLengthLimit);
|
||||||
|
|
||||||
|
// Queue the episode for analysis
|
||||||
|
var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumCreditsDuration;
|
||||||
|
_queuedEpisodes[episode.SeasonId].Add(new QueuedEpisode()
|
||||||
|
{
|
||||||
|
SeriesName = episode.SeriesName,
|
||||||
|
SeasonNumber = episode.AiredSeasonNumber ?? 0,
|
||||||
|
EpisodeId = episode.Id,
|
||||||
|
Name = episode.Name,
|
||||||
|
Path = episode.Path,
|
||||||
|
Duration = Convert.ToInt32(duration),
|
||||||
|
IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration),
|
||||||
|
CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration),
|
||||||
|
});
|
||||||
|
|
||||||
|
Plugin.Instance!.TotalQueued++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 (ReadOnlyCollection<QueuedEpisode> VerifiedItems, ReadOnlyCollection<AnalysisMode> RequiredModes)
|
||||||
|
VerifyQueue(ReadOnlyCollection<QueuedEpisode> candidates, ReadOnlyCollection<AnalysisMode> modes)
|
||||||
|
{
|
||||||
|
var verified = new List<QueuedEpisode>();
|
||||||
|
var reqModes = new List<AnalysisMode>();
|
||||||
|
|
||||||
|
var requiresIntroAnalysis = modes.Contains(AnalysisMode.Introduction);
|
||||||
|
var requiresCreditsAnalysis = modes.Contains(AnalysisMode.Credits);
|
||||||
|
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
verified.Add(candidate);
|
||||||
|
|
||||||
|
if (requiresIntroAnalysis && (!Plugin.Instance!.Intros.TryGetValue(candidate.EpisodeId, out var intro) || !intro.Valid))
|
||||||
|
{
|
||||||
|
reqModes.Add(AnalysisMode.Introduction);
|
||||||
|
requiresIntroAnalysis = false; // No need to check again
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresCreditsAnalysis && (!Plugin.Instance!.Credits.TryGetValue(candidate.EpisodeId, out var credit) || !credit.Valid))
|
||||||
|
{
|
||||||
|
reqModes.Add(AnalysisMode.Credits);
|
||||||
|
requiresCreditsAnalysis = false; // No need to check again
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping {Mode} analysis of {Name} ({Id}): {Exception}",
|
||||||
|
modes,
|
||||||
|
candidate.Name,
|
||||||
|
candidate.EpisodeId,
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (verified.AsReadOnly(), reqModes.AsReadOnly());
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,243 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Common code shared by all media item analyzer tasks.
|
||||||
|
/// </summary>
|
||||||
|
public class BaseItemAnalyzerTask
|
||||||
|
{
|
||||||
|
private readonly ReadOnlyCollection<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(
|
||||||
|
ReadOnlyCollection<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>
|
||||||
|
public void AnalyzeItems(
|
||||||
|
IProgress<double> progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
var totalQueued = 0;
|
||||||
|
foreach (var kvp in queue)
|
||||||
|
{
|
||||||
|
totalQueued += kvp.Value.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalQueued *= _analysisModes.Count;
|
||||||
|
|
||||||
|
if (totalQueued == 0)
|
||||||
|
{
|
||||||
|
throw new FingerprintException(
|
||||||
|
"No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
|
||||||
|
{
|
||||||
|
EdlManager.LogConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalProcessed = 0;
|
||||||
|
var modeCount = _analysisModes.Count;
|
||||||
|
var options = new ParallelOptions()
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
|
||||||
|
};
|
||||||
|
|
||||||
|
Parallel.ForEach(queue, options, (season) =>
|
||||||
|
{
|
||||||
|
var writeEdl = false;
|
||||||
|
|
||||||
|
var totalRemaining = (Plugin.Instance!.TotalQueued * modeCount) - totalProcessed;
|
||||||
|
|
||||||
|
if (totalRemaining >= queue.Count * modeCount)
|
||||||
|
{
|
||||||
|
queue = new(Plugin.Instance!.QueuedMediaItems);
|
||||||
|
totalQueued = 0;
|
||||||
|
foreach (var kvp in queue)
|
||||||
|
{
|
||||||
|
totalQueued += kvp.Value.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalQueued *= _analysisModes.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.AsReadOnly(),
|
||||||
|
_analysisModes);
|
||||||
|
|
||||||
|
var episodeCount = episodes.Count;
|
||||||
|
|
||||||
|
if (episodeCount == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = episodes[0];
|
||||||
|
var requiredModeCount = requiredModes.Count;
|
||||||
|
|
||||||
|
if (requiredModeCount == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"All episodes in {Name} season {Season} have already been analyzed",
|
||||||
|
first.SeriesName,
|
||||||
|
first.SeasonNumber);
|
||||||
|
|
||||||
|
Interlocked.Add(ref totalProcessed, episodeCount * modeCount); // Update total Processed directly
|
||||||
|
progress.Report((totalProcessed * 100) / totalQueued);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeCount != requiredModeCount)
|
||||||
|
{
|
||||||
|
Interlocked.Add(ref totalProcessed, episodeCount);
|
||||||
|
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(
|
||||||
|
ReadOnlyCollection<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}",
|
||||||
|
mode,
|
||||||
|
items.Count,
|
||||||
|
first.SeriesName,
|
||||||
|
first.SeasonNumber);
|
||||||
|
|
||||||
|
var analyzers = new Collection<IMediaFileAnalyzer>();
|
||||||
|
|
||||||
|
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
|
||||||
|
if (mode == AnalysisMode.Credits)
|
||||||
|
{
|
||||||
|
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalItems;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,116 @@
|
|||||||
|
// 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.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduledTaskSemaphore.Wait(-1, cancellationToken);
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
ScheduledTaskSemaphore.Release();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Scheduled Task is starting");
|
||||||
|
|
||||||
|
Plugin.Instance!.Configuration.PathRestrictions.Clear();
|
||||||
|
var modes = new List<AnalysisMode> { AnalysisMode.Credits };
|
||||||
|
|
||||||
|
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
|
||||||
|
modes.AsReadOnly(),
|
||||||
|
_loggerFactory.CreateLogger<DetectCreditsTask>(),
|
||||||
|
_loggerFactory,
|
||||||
|
_libraryManager);
|
||||||
|
|
||||||
|
baseCreditAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||||
|
|
||||||
|
ScheduledTaskSemaphore.Release();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get task triggers.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Task triggers.</returns>
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
return Array.Empty<TaskTriggerInfo>();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,122 @@
|
|||||||
|
// 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.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduledTaskSemaphore.Wait(-1, cancellationToken);
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
ScheduledTaskSemaphore.Release();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Scheduled Task is starting");
|
||||||
|
|
||||||
|
Plugin.Instance!.Configuration.PathRestrictions.Clear();
|
||||||
|
var modes = new List<AnalysisMode> { AnalysisMode.Introduction, AnalysisMode.Credits };
|
||||||
|
|
||||||
|
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
|
||||||
|
modes.AsReadOnly(),
|
||||||
|
_loggerFactory.CreateLogger<DetectIntrosCreditsTask>(),
|
||||||
|
_loggerFactory,
|
||||||
|
_libraryManager);
|
||||||
|
|
||||||
|
baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||||
|
|
||||||
|
ScheduledTaskSemaphore.Release();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get task triggers.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Task triggers.</returns>
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new TaskTriggerInfo
|
||||||
|
{
|
||||||
|
Type = TaskTriggerInfo.TriggerDaily,
|
||||||
|
TimeOfDayTicks = TimeSpan.FromHours(0).Ticks
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,115 @@
|
|||||||
|
// 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.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduledTaskSemaphore.Wait(-1, cancellationToken);
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
ScheduledTaskSemaphore.Release();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Scheduled Task is starting");
|
||||||
|
|
||||||
|
Plugin.Instance!.Configuration.PathRestrictions.Clear();
|
||||||
|
var modes = new List<AnalysisMode> { AnalysisMode.Introduction };
|
||||||
|
|
||||||
|
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
|
||||||
|
modes.AsReadOnly(),
|
||||||
|
_loggerFactory.CreateLogger<DetectIntrosTask>(),
|
||||||
|
_loggerFactory,
|
||||||
|
_libraryManager);
|
||||||
|
|
||||||
|
baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||||
|
|
||||||
|
ScheduledTaskSemaphore.Release();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get task triggers.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Task triggers.</returns>
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
return Array.Empty<TaskTriggerInfo>();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
internal sealed class ScheduledTaskSemaphore : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
|
||||||
|
|
||||||
|
private ScheduledTaskSemaphore()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int CurrentCount => _semaphore.CurrentCount;
|
||||||
|
|
||||||
|
public static bool Wait(int timeout, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _semaphore.Wait(timeout, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int Release()
|
||||||
|
{
|
||||||
|
return _semaphore.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispose.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Protected dispose.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="disposing">Dispose.</param>
|
||||||
|
private void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (!disposing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_semaphore.Dispose();
|
||||||
|
}
|
||||||
|
}
|
@ -1,52 +0,0 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
|
||||||
|
|
||||||
namespace IntroSkipper.Tests;
|
|
||||||
|
|
||||||
using IntroSkipper.Data;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
public class TestFlags
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void TestEmptyFlagSerialization()
|
|
||||||
{
|
|
||||||
WarningManager.Clear();
|
|
||||||
Assert.Equal("None", WarningManager.GetWarnings());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TestSingleFlagSerialization()
|
|
||||||
{
|
|
||||||
WarningManager.Clear();
|
|
||||||
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
|
|
||||||
Assert.Equal("UnableToAddSkipButton", WarningManager.GetWarnings());
|
|
||||||
Assert.True(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TestDoubleFlagSerialization()
|
|
||||||
{
|
|
||||||
WarningManager.Clear();
|
|
||||||
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
|
|
||||||
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
|
|
||||||
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
|
|
||||||
Assert.True(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton) && WarningManager.HasFlag(PluginWarning.InvalidChromaprintFingerprint));
|
|
||||||
Assert.Equal(
|
|
||||||
"UnableToAddSkipButton, InvalidChromaprintFingerprint",
|
|
||||||
WarningManager.GetWarnings());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TestHasFlag()
|
|
||||||
{
|
|
||||||
WarningManager.Clear();
|
|
||||||
Assert.True(WarningManager.HasFlag(PluginWarning.None));
|
|
||||||
Assert.False(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton) && WarningManager.HasFlag(PluginWarning.InvalidChromaprintFingerprint));
|
|
||||||
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
|
|
||||||
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
|
|
||||||
Assert.True(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton) && WarningManager.HasFlag(PluginWarning.InvalidChromaprintFingerprint));
|
|
||||||
Assert.False(WarningManager.HasFlag(PluginWarning.IncompatibleFFmpegBuild));
|
|
||||||
Assert.True(WarningManager.HasFlag(PluginWarning.None));
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,218 +0,0 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using 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,172 +0,0 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IntroSkipper.Configuration;
|
|
||||||
using IntroSkipper.Data;
|
|
||||||
using MediaBrowser.Model.Entities;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace IntroSkipper.Analyzers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Chapter name analyzer.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Initializes a new instance of the <see cref="ChapterAnalyzer"/> class.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="logger">Logger.</param>
|
|
||||||
public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyzer
|
|
||||||
{
|
|
||||||
private readonly ILogger<ChapterAnalyzer> _logger = logger;
|
|
||||||
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
|
||||||
IReadOnlyList<QueuedEpisode> analysisQueue,
|
|
||||||
AnalysisMode mode,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
|
|
||||||
|
|
||||||
foreach (var episode in episodesWithoutIntros)
|
|
||||||
{
|
|
||||||
if (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var skipRange = FindMatchingChapter(
|
|
||||||
episode,
|
|
||||||
Plugin.Instance!.GetChapters(episode.EpisodeId),
|
|
||||||
expression,
|
|
||||||
mode);
|
|
||||||
|
|
||||||
if (skipRange is null || !skipRange.Valid)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
episode.IsAnalyzed = true;
|
|
||||||
await Plugin.Instance!.UpdateTimestampAsync(skipRange, mode).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return analysisQueue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Searches a list of chapter names for one that matches the provided regular expression.
|
|
||||||
/// Only public to allow for unit testing.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="episode">Episode.</param>
|
|
||||||
/// <param name="chapters">Media item chapters.</param>
|
|
||||||
/// <param name="expression">Regular expression pattern.</param>
|
|
||||||
/// <param name="mode">Analysis mode.</param>
|
|
||||||
/// <returns>Intro object containing skippable time range, or null if no chapter matched.</returns>
|
|
||||||
public Segment? FindMatchingChapter(
|
|
||||||
QueuedEpisode episode,
|
|
||||||
IReadOnlyList<ChapterInfo> chapters,
|
|
||||||
string expression,
|
|
||||||
AnalysisMode mode)
|
|
||||||
{
|
|
||||||
var count = chapters.Count;
|
|
||||||
if (count == 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var creditDuration = episode.IsMovie ? _config.MaximumMovieCreditsDuration : _config.MaximumCreditsDuration;
|
|
||||||
var reversed = mode == AnalysisMode.Credits;
|
|
||||||
var (minDuration, maxDuration) = reversed
|
|
||||||
? (_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)
|
|
||||||
{
|
|
||||||
var chapter = chapters[i];
|
|
||||||
var next = chapters.ElementAtOrDefault(i + 1) ??
|
|
||||||
new ChapterInfo { StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks }; // Since the ending credits chapter may be the last chapter in the file, append a virtual chapter.
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(chapter.Name))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentRange = new TimeRange(
|
|
||||||
TimeSpan.FromTicks(chapter.StartPositionTicks).TotalSeconds,
|
|
||||||
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
|
|
||||||
|
|
||||||
var baseMessage = string.Format(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
"{0}: Chapter \"{1}\" ({2} - {3})",
|
|
||||||
episode.Path,
|
|
||||||
chapter.Name,
|
|
||||||
currentRange.Start,
|
|
||||||
currentRange.End);
|
|
||||||
|
|
||||||
if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration)
|
|
||||||
{
|
|
||||||
_logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex
|
|
||||||
// between function invocations.
|
|
||||||
var match = Regex.IsMatch(
|
|
||||||
chapter.Name,
|
|
||||||
expression,
|
|
||||||
RegexOptions.IgnoreCase,
|
|
||||||
TimeSpan.FromSeconds(1));
|
|
||||||
|
|
||||||
if (!match)
|
|
||||||
{
|
|
||||||
_logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the next (or previous for Credits) chapter also matches
|
|
||||||
var adjacentChapter = reversed ? chapters.ElementAtOrDefault(i - 1) : next;
|
|
||||||
if (adjacentChapter != null && !string.IsNullOrWhiteSpace(adjacentChapter.Name))
|
|
||||||
{
|
|
||||||
// Check for possibility of overlapping keywords
|
|
||||||
var overlap = Regex.IsMatch(
|
|
||||||
adjacentChapter.Name,
|
|
||||||
expression,
|
|
||||||
RegexOptions.None,
|
|
||||||
TimeSpan.FromSeconds(1));
|
|
||||||
|
|
||||||
if (overlap)
|
|
||||||
{
|
|
||||||
_logger.LogTrace("{Base}: ignoring (adjacent chapter also matches)", baseMessage);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogTrace("{Base}: okay", baseMessage);
|
|
||||||
return new Segment(episode.EpisodeId, currentRange);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
@ -1,597 +0,0 @@
|
|||||||
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();
|
|
@ -1,274 +0,0 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.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,156 +0,0 @@
|
|||||||
// 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 IntroSkipper.Data;
|
|
||||||
using IntroSkipper.Helper;
|
|
||||||
using MediaBrowser.Common;
|
|
||||||
using MediaBrowser.Common.Api;
|
|
||||||
using MediaBrowser.Controller.Library;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace IntroSkipper.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Troubleshooting controller.
|
|
||||||
/// </summary>
|
|
||||||
[Authorize(Policy = Policies.RequiresElevation)]
|
|
||||||
[ApiController]
|
|
||||||
[Produces(MediaTypeNames.Application.Json)]
|
|
||||||
[Route("IntroSkipper")]
|
|
||||||
public class TroubleshootingController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ILibraryManager _libraryManager;
|
|
||||||
private readonly IApplicationHost _applicationHost;
|
|
||||||
private readonly ILogger<TroubleshootingController> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="TroubleshootingController"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="applicationHost">Application host.</param>
|
|
||||||
/// <param name="libraryManager">Library Manager.</param>
|
|
||||||
/// <param name="logger">Logger.</param>
|
|
||||||
public TroubleshootingController(
|
|
||||||
IApplicationHost applicationHost,
|
|
||||||
ILibraryManager libraryManager,
|
|
||||||
ILogger<TroubleshootingController> logger)
|
|
||||||
{
|
|
||||||
_libraryManager = libraryManager;
|
|
||||||
_applicationHost = applicationHost;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a Markdown formatted support bundle.
|
|
||||||
/// </summary>
|
|
||||||
/// <response code="200">Support bundle created.</response>
|
|
||||||
/// <returns>Support bundle.</returns>
|
|
||||||
[HttpGet("SupportBundle")]
|
|
||||||
[Produces(MediaTypeNames.Text.Plain)]
|
|
||||||
public ActionResult<string> GetSupportBundle()
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(Plugin.Instance);
|
|
||||||
|
|
||||||
var bundle = new StringBuilder();
|
|
||||||
|
|
||||||
bundle.Append("* Jellyfin version: ");
|
|
||||||
bundle.Append(_applicationHost.ApplicationVersionString);
|
|
||||||
bundle.Append('\n');
|
|
||||||
|
|
||||||
var version = Plugin.Instance.Version.ToString(3);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var commit = Commit.CommitHash;
|
|
||||||
if (!string.IsNullOrWhiteSpace(commit))
|
|
||||||
{
|
|
||||||
version += string.Concat("+", commit.AsSpan(0, 12));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Unable to append commit to version: {Exception}", ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
bundle.Append("* Plugin version: ");
|
|
||||||
bundle.Append(version);
|
|
||||||
bundle.Append('\n');
|
|
||||||
|
|
||||||
bundle.Append("* Queue contents: ");
|
|
||||||
bundle.Append(Plugin.Instance.TotalQueued);
|
|
||||||
bundle.Append(" episodes, ");
|
|
||||||
bundle.Append(Plugin.Instance.TotalSeasons);
|
|
||||||
bundle.Append(" seasons\n");
|
|
||||||
|
|
||||||
bundle.Append("* Warnings: `");
|
|
||||||
bundle.Append(WarningManager.GetWarnings());
|
|
||||||
bundle.Append("`\n");
|
|
||||||
|
|
||||||
bundle.Append(FFmpegWrapper.GetChromaprintLogs());
|
|
||||||
|
|
||||||
return bundle.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a Markdown formatted support bundle.
|
|
||||||
/// </summary>
|
|
||||||
/// <response code="200">Support bundle created.</response>
|
|
||||||
/// <returns>Support bundle.</returns>
|
|
||||||
[HttpGet("Storage")]
|
|
||||||
[Produces(MediaTypeNames.Text.Plain)]
|
|
||||||
public ActionResult<string> GetFreeSpace()
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(Plugin.Instance);
|
|
||||||
var bundle = new StringBuilder();
|
|
||||||
|
|
||||||
var libraries = _libraryManager.GetVirtualFolders();
|
|
||||||
foreach (var library in libraries)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
DriveInfo driveInfo = new DriveInfo(library.Locations[0]);
|
|
||||||
// Get available free space in bytes
|
|
||||||
long availableFreeSpace = driveInfo.AvailableFreeSpace;
|
|
||||||
|
|
||||||
// Get total size of the drive in bytes
|
|
||||||
long totalSize = driveInfo.TotalSize;
|
|
||||||
|
|
||||||
// Get total used space in Percentage
|
|
||||||
double usedSpacePercentage = totalSize > 0 ? (totalSize - availableFreeSpace) / (double)totalSize * 100 : 0;
|
|
||||||
|
|
||||||
bundle.Append(CultureInfo.CurrentCulture, $"Library: {library.Name}\n");
|
|
||||||
bundle.Append(CultureInfo.CurrentCulture, $"Drive: {driveInfo.Name}\n");
|
|
||||||
bundle.Append(CultureInfo.CurrentCulture, $"Total Size: {GetHumanReadableSize(totalSize)}\n");
|
|
||||||
bundle.Append(CultureInfo.CurrentCulture, $"Available Free Space: {GetHumanReadableSize(availableFreeSpace)}\n");
|
|
||||||
bundle.Append(CultureInfo.CurrentCulture, $"Total used in Percentage: {Math.Round(usedSpacePercentage, 2)}%\n\n");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Unable to get DriveInfo: {Exception}", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bundle.ToString().TrimEnd('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetHumanReadableSize(long bytes)
|
|
||||||
{
|
|
||||||
string[] sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
|
||||||
double len = bytes;
|
|
||||||
int order = 0;
|
|
||||||
|
|
||||||
while (len >= 1024 && order < sizes.Length - 1)
|
|
||||||
{
|
|
||||||
order++;
|
|
||||||
len /= 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $"{len:0.##} {sizes[order]}";
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,249 +0,0 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Mime;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using IntroSkipper.Data;
|
|
||||||
using IntroSkipper.Db;
|
|
||||||
using IntroSkipper.Manager;
|
|
||||||
using MediaBrowser.Common.Api;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace IntroSkipper.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 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, MediaSegmentUpdateManager mediaSegmentUpdateManager) : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ILogger<VisualizationController> _logger = logger;
|
|
||||||
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns all show names and seasons.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Dictionary of show names to a list of season names.</returns>
|
|
||||||
[HttpGet("Shows")]
|
|
||||||
public ActionResult<Dictionary<Guid, ShowInfos>> GetShowSeasons()
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Returning season IDs by series name");
|
|
||||||
|
|
||||||
var showSeasons = new Dictionary<Guid, ShowInfos>();
|
|
||||||
|
|
||||||
foreach (var kvp in Plugin.Instance!.QueuedMediaItems)
|
|
||||||
{
|
|
||||||
if (kvp.Value.FirstOrDefault() is QueuedEpisode first)
|
|
||||||
{
|
|
||||||
var seriesId = first.SeriesId;
|
|
||||||
var seasonId = kvp.Key;
|
|
||||||
|
|
||||||
var seasonNumber = first.SeasonNumber;
|
|
||||||
if (!showSeasons.TryGetValue(seriesId, out var showInfo))
|
|
||||||
{
|
|
||||||
showInfo = new ShowInfos { SeriesName = first.SeriesName, ProductionYear = GetProductionYear(seriesId), LibraryName = GetLibraryName(seriesId), IsMovie = first.IsMovie, Seasons = [] };
|
|
||||||
showSeasons[seriesId] = showInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
showInfo.Seasons[seasonId] = seasonNumber;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort the dictionary by SeriesName and the seasons by SeasonName
|
|
||||||
var sortedShowSeasons = showSeasons
|
|
||||||
.OrderBy(kvp => kvp.Value.SeriesName)
|
|
||||||
.ToDictionary(
|
|
||||||
kvp => kvp.Key,
|
|
||||||
kvp => new ShowInfos
|
|
||||||
{
|
|
||||||
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)
|
|
||||||
});
|
|
||||||
|
|
||||||
return sortedShowSeasons;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the analyzer actions for the provided season.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="seasonId">Season ID.</param>
|
|
||||||
/// <returns>List of episode titles.</returns>
|
|
||||||
[HttpGet("AnalyzerActions/{SeasonId}")]
|
|
||||||
public ActionResult<IReadOnlyDictionary<AnalysisMode, AnalyzerAction>> GetAnalyzerAction([FromRoute] Guid seasonId)
|
|
||||||
{
|
|
||||||
if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(seasonId))
|
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
var analyzerActions = new Dictionary<AnalysisMode, AnalyzerAction>();
|
|
||||||
foreach (var mode in Enum.GetValues<AnalysisMode>())
|
|
||||||
{
|
|
||||||
analyzerActions[mode] = Plugin.Instance!.GetAnalyzerAction(seasonId, mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(analyzerActions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the names and unique identifiers of all episodes in the provided season.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="seriesId">Show ID.</param>
|
|
||||||
/// <param name="seasonId">Season ID.</param>
|
|
||||||
/// <returns>List of episode titles.</returns>
|
|
||||||
[HttpGet("Show/{SeriesId}/{SeasonId}")]
|
|
||||||
public ActionResult<List<EpisodeVisualization>> GetSeasonEpisodes([FromRoute] Guid seriesId, [FromRoute] Guid seasonId)
|
|
||||||
{
|
|
||||||
if (!Plugin.Instance!.QueuedMediaItems.TryGetValue(seasonId, out var episodes))
|
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!episodes.Any(e => e.SeriesId == seriesId))
|
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
var showName = episodes.FirstOrDefault()?.SeriesName!;
|
|
||||||
|
|
||||||
return episodes.Select(e => new EpisodeVisualization(e.EpisodeId, e.Name)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fingerprint the provided episode and returns the uncompressed fingerprint data points.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">Episode id.</param>
|
|
||||||
/// <returns>Read only collection of fingerprint points.</returns>
|
|
||||||
[HttpGet("Episode/{Id}/Chromaprint")]
|
|
||||||
public ActionResult<uint[]> GetEpisodeFingerprint([FromRoute] Guid id)
|
|
||||||
{
|
|
||||||
// Search through all queued episodes to find the requested id
|
|
||||||
foreach (var season in Plugin.Instance!.QueuedMediaItems)
|
|
||||||
{
|
|
||||||
foreach (var needle in season.Value)
|
|
||||||
{
|
|
||||||
if (needle.EpisodeId == id)
|
|
||||||
{
|
|
||||||
return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Erases all timestamps for the provided season.
|
|
||||||
/// </summary>
|
|
||||||
/// <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 async Task<ActionResult> EraseSeasonAsync([FromRoute] Guid seriesId, [FromRoute] Guid seasonId, [FromQuery] bool eraseCache = false, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var episodes = Plugin.Instance!.QueuedMediaItems[seasonId];
|
|
||||||
|
|
||||||
if (episodes.Count == 0)
|
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Erasing timestamps for series {SeriesId} season {SeasonId} at user request", seriesId, seasonId);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
await Task.Run(() => FFmpegWrapper.DeleteEpisodeCache(episode.EpisodeId), cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var seasonInfo = db.DbSeasonInfo.Where(s => s.SeasonId == seasonId);
|
|
||||||
|
|
||||||
foreach (var info in seasonInfo)
|
|
||||||
{
|
|
||||||
db.Entry(info).Property(s => s.EpisodeIds).CurrentValue = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (Plugin.Instance.Configuration.UpdateMediaSegments)
|
|
||||||
{
|
|
||||||
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the analyzer actions for the provided season.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Update analyzer actions request.</param>
|
|
||||||
/// <returns>No content.</returns>
|
|
||||||
[HttpPost("AnalyzerActions/UpdateSeason")]
|
|
||||||
public async Task<ActionResult> UpdateAnalyzerActions([FromBody] UpdateAnalyzerActionsRequest request)
|
|
||||||
{
|
|
||||||
await Plugin.Instance!.SetAnalyzerActionAsync(request.Id, request.AnalyzerActions).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetProductionYear(Guid seriesId)
|
|
||||||
{
|
|
||||||
return seriesId == Guid.Empty
|
|
||||||
? "Unknown"
|
|
||||||
: Plugin.Instance?.GetItem(seriesId)?.ProductionYear?.ToString(CultureInfo.InvariantCulture) ?? "Unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetLibraryName(Guid seriesId)
|
|
||||||
{
|
|
||||||
if (seriesId == Guid.Empty)
|
|
||||||
{
|
|
||||||
return "Unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
var collectionFolders = Plugin.Instance?.GetCollectionFolders(seriesId);
|
|
||||||
return collectionFolders?.Count > 0
|
|
||||||
? string.Join(", ", collectionFolders.Select(folder => folder.Name))
|
|
||||||
: "Unknown";
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-only.
|
|
||||||
|
|
||||||
namespace IntroSkipper.Data;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Type of media file analysis to perform.
|
|
||||||
/// </summary>
|
|
||||||
public enum AnalyzerAction
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Default action.
|
|
||||||
/// </summary>
|
|
||||||
Default,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Detect chapters.
|
|
||||||
/// </summary>
|
|
||||||
Chapter,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Detect chromaprint fingerprints.
|
|
||||||
/// </summary>
|
|
||||||
Chromaprint,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Detect black frames.
|
|
||||||
/// </summary>
|
|
||||||
BlackFrame,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// No action.
|
|
||||||
/// </summary>
|
|
||||||
None,
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
// 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.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Initializes a new instance of the <see cref="BlackFrame"/> class.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="percent">Percentage of the frame that is black.</param>
|
|
||||||
/// <param name="time">Time this frame appears at.</param>
|
|
||||||
public class BlackFrame(int percent, double time)
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the percentage of the frame that is black.
|
|
||||||
/// </summary>
|
|
||||||
public int Percentage { get; set; } = percent;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the time (in seconds) this frame appeared at.
|
|
||||||
/// </summary>
|
|
||||||
public double Time { get; set; } = time;
|
|
||||||
}
|
|
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