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 |
@ -192,3 +192,5 @@ csharp_space_between_method_call_empty_parameter_list_parentheses = false
|
|||||||
# Wrapping preferences
|
# Wrapping preferences
|
||||||
csharp_preserve_single_line_statements = true
|
csharp_preserve_single_line_statements = true
|
||||||
csharp_preserve_single_line_blocks = true
|
csharp_preserve_single_line_blocks = true
|
||||||
|
|
||||||
|
file_header_template = Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>\nSPDX-License-Identifier: GPL-3.0-only.
|
27
.github/ISSUE_TEMPLATE/bug_report_form.yml
vendored
27
.github/ISSUE_TEMPLATE/bug_report_form.yml
vendored
@ -3,6 +3,18 @@ description: "Create a report to help us improve"
|
|||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: [bug]
|
labels: [bug]
|
||||||
body:
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: requirements
|
||||||
|
attributes:
|
||||||
|
label: Self service debugging
|
||||||
|
description: |
|
||||||
|
Jellyfin 10.9 is still being actively updated. Please make sure you are using the newest release
|
||||||
|
|
||||||
|
Docker containers have known permission issues that can be resolved with a few extra steps.
|
||||||
|
If your skip button is not shown, please see https://github.com/jumoog/intro-skipper/issues/104
|
||||||
|
options:
|
||||||
|
- label: Jellyfin is updated and my permissions are correct (or I did not use Docker)
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Describe the bug
|
label: Describe the bug
|
||||||
@ -22,24 +34,33 @@ body:
|
|||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: Jellyfin installation method
|
label: Jellyfin install method
|
||||||
|
description: How you installed Jellyfin or the tool used to install it
|
||||||
placeholder: Docker, Windows installer, etc.
|
placeholder: Docker, Windows installer, etc.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: Container image and tag
|
label: Container image/tag or Jellyfin version
|
||||||
description: Only fill in this field if you are running Jellyfin in a container
|
description: The container for Docker or Jellyfin version for a native install
|
||||||
placeholder: jellyfin/jellyfin:10.8.7, jellyfin-intro-skipper:latest, etc.
|
placeholder: jellyfin/jellyfin:10.8.7, jellyfin-intro-skipper:latest, etc.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: Operating System
|
label: Operating System
|
||||||
|
description: The operating system of the Jellyfin / Docker host computer
|
||||||
placeholder: Debian 11, Windows 11, etc.
|
placeholder: Debian 11, Windows 11, etc.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: IMDb ID of that TV Series
|
||||||
|
placeholder: tt0903747
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Support Bundle
|
label: Support Bundle
|
||||||
|
67
.github/workflows/build.yml
vendored
67
.github/workflows/build.yml
vendored
@ -2,16 +2,28 @@ name: 'Build Plugin'
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "master" ]
|
branches: [ "10.8" ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**/README.md'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'images/**'
|
||||||
|
- 'manifest.json'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "master" ]
|
branches: [ "10.8" ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**/README.md'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'images/**'
|
||||||
|
- 'manifest.json'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ ! startsWith(github.event.head_commit.message, 'v0.1') }}
|
if: ${{ ! startsWith(github.event.head_commit.message, 'v0.') }}
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
@ -23,6 +35,20 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: 6.0.x
|
dotnet-version: 6.0.x
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
|
||||||
|
- name: Install html-minifier-terser
|
||||||
|
run: npm install terser html-minifier-terser
|
||||||
|
|
||||||
|
- name: Minify HTML
|
||||||
|
run: |
|
||||||
|
npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --collapse-inline-tag-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html
|
||||||
|
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m
|
||||||
|
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -c -m
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
@ -38,12 +64,22 @@ jobs:
|
|||||||
run: dotnet build --no-restore
|
run: dotnet build --no-restore
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4.3.1
|
uses: actions/upload-artifact@v4.3.3
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
with:
|
with:
|
||||||
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.GIT_HASH }}.dll
|
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.GIT_HASH }}.dll
|
||||||
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||||
if-no-files-found: error
|
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
|
||||||
|
|
||||||
- name: Create archive
|
- name: Create archive
|
||||||
uses: vimtor/action-zip@v1.2
|
uses: vimtor/action-zip@v1.2
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@ -54,16 +90,33 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate md5
|
- name: Generate md5
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
run: md5sum intro-skipper-${{ env.GIT_HASH }}.zip > intro-skipper-${{ env.GIT_HASH }}.zip.md5
|
run: |
|
||||||
|
md5sum intro-skipper-${{ env.GIT_HASH }}.zip > intro-skipper-${{ env.GIT_HASH }}.md5
|
||||||
|
checksum="$(awk '{print $1}' intro-skipper-${{ env.GIT_HASH }}.md5)"
|
||||||
|
echo "CHECKSUM=$checksum" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Publish prerelease
|
- name: Publish prerelease
|
||||||
uses: 8bitDream/action-github-releases@v1.0.0
|
uses: 8bitDream/action-github-releases@v1.0.0
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
with:
|
with:
|
||||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
automatic_release_tag: preview
|
automatic_release_tag: 10.8/preview
|
||||||
prerelease: true
|
prerelease: true
|
||||||
title: intro-skipper-${{ env.GIT_HASH }}
|
title: intro-skipper-${{ env.GIT_HASH }}
|
||||||
files: |
|
files: |
|
||||||
intro-skipper-${{ env.GIT_HASH }}.zip
|
intro-skipper-${{ env.GIT_HASH }}.zip
|
||||||
intro-skipper-${{ env.GIT_HASH }}.zip.md5
|
|
||||||
|
- 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:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
52
.github/workflows/codeql.yml
vendored
Normal file
52
.github/workflows/codeql.yml
vendored
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ 10.8 ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**/README.md'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'images/**'
|
||||||
|
- 'manifest.json'
|
||||||
|
pull_request:
|
||||||
|
branches: [ 10.8 ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**/README.md'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'images/**'
|
||||||
|
- 'manifest.json'
|
||||||
|
|
||||||
|
permissions: write-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'csharp' ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 6.0.x
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
queries: +security-extended
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
|
85
.github/workflows/release.yml
vendored
85
.github/workflows/release.yml
vendored
@ -2,11 +2,6 @@ name: 'Release Plugin'
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Version v'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@ -19,25 +14,47 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 6.0.x
|
dotnet-version: 6.0.x
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
|
||||||
|
- name: Install html-minifier-terser
|
||||||
|
run: npm install terser html-minifier-terser
|
||||||
|
|
||||||
|
- name: Minify HTML
|
||||||
|
run: |
|
||||||
|
npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --collapse-inline-tag-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html
|
||||||
|
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m
|
||||||
|
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -c -m
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Run update version
|
||||||
|
run: node update-version.js
|
||||||
|
|
||||||
- name: Embed version info
|
- name: Embed version info
|
||||||
run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt
|
run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build --no-restore
|
run: dotnet build --configuration Release --no-restore
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4.3.1
|
uses: actions/upload-artifact@v4.3.3
|
||||||
with:
|
with:
|
||||||
name: ConfusedPolarBear.Plugin.IntroSkipper-v${{ github.event.inputs.version }}.dll
|
name: ConfusedPolarBear.Plugin.IntroSkipper-v${{ env.NEW_FILE_VERSION }}.dll
|
||||||
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Release/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Create archive
|
- name: Create archive
|
||||||
@ -45,28 +62,48 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||||
dest: intro-skipper-v${{ github.event.inputs.version }}.zip
|
dest: intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip
|
||||||
|
|
||||||
- name: Generate md5
|
- name: Generate manifest keys
|
||||||
run: |
|
run: |
|
||||||
md5sum intro-skipper-v${{ github.event.inputs.version }}.zip > intro-skipper-v${{ github.event.inputs.version }}.zip.md5
|
sourceUrl="https://github.com/${{ github.repository }}/releases/download/10.8/v${{ env.NEW_FILE_VERSION }}/intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip"
|
||||||
echo "sourceUrl: https://github.com/${{ github.repository }}/releases/download/v${{ github.event.inputs.version }}/intro-skipper-v${{ github.event.inputs.version }}.zip"
|
echo "SOURCE_URL=$sourceUrl" >> $GITHUB_ENV
|
||||||
echo "checksum: $(awk '{print $1}' intro-skipper-v${{ github.event.inputs.version }}.zip.md5)"
|
md5sum intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip > intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5
|
||||||
echo "timestamp: $(date +%FT%TZ)"
|
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: Publish release
|
- name: Publish release
|
||||||
uses: 8bitDream/action-github-releases@v1.0.0
|
uses: 8bitDream/action-github-releases@v1.0.0
|
||||||
with:
|
with:
|
||||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
automatic_release_tag: v${{ github.event.inputs.version }}
|
automatic_release_tag: 10.8/v${{ env.NEW_FILE_VERSION }}
|
||||||
prerelease: false
|
prerelease: false
|
||||||
title: v${{ github.event.inputs.version }}
|
title: v${{ env.NEW_FILE_VERSION }}
|
||||||
files: |
|
files: |
|
||||||
intro-skipper-v${{ github.event.inputs.version }}.zip
|
intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip
|
||||||
intro-skipper-v${{ github.event.inputs.version }}.zip.md5
|
|
||||||
|
|
||||||
# - name: Push changes
|
- name: Publish release notes
|
||||||
# uses: ad-m/github-push-action@master
|
uses: softprops/action-gh-release@v2.0.5
|
||||||
# with:
|
with:
|
||||||
# github_token: ${{ secrets.GITHUB_TOKEN }}
|
tag_name: 10.8/v${{ env.NEW_FILE_VERSION }}
|
||||||
# branch: master
|
name: v${{ env.NEW_FILE_VERSION }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Run validation and update script
|
||||||
|
run: node validate-and-update-manifest.js
|
||||||
|
env:
|
||||||
|
VERSION: ${{ env.NEW_FILE_VERSION }}
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
git config --global user.name "github-actions[bot]"
|
||||||
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add manifest.json ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj
|
||||||
|
git commit -m "release v${{ env.NEW_FILE_VERSION }}"
|
||||||
|
git push
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,3 +8,6 @@ docker/dist
|
|||||||
|
|
||||||
# Visual Studio
|
# Visual Studio
|
||||||
.vs/
|
.vs/
|
||||||
|
|
||||||
|
# JetBrains Rider
|
||||||
|
.idea/
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
/* These tests require that the host system has a version of FFmpeg installed
|
/* These tests require that the host system has a version of FFmpeg installed
|
||||||
* which supports both chromaprint and the "-fp_format raw" flag.
|
* which supports both chromaprint and the "-fp_format raw" flag.
|
||||||
*/
|
*/
|
||||||
@ -81,7 +84,7 @@ public class TestAudioFingerprinting
|
|||||||
{77, 5},
|
{77, 5},
|
||||||
};
|
};
|
||||||
|
|
||||||
var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr);
|
var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction);
|
||||||
|
|
||||||
Assert.Equal(expected, actual);
|
Assert.Equal(expected, actual);
|
||||||
}
|
}
|
||||||
@ -104,11 +107,12 @@ public class TestAudioFingerprinting
|
|||||||
|
|
||||||
Assert.True(lhs.Valid);
|
Assert.True(lhs.Valid);
|
||||||
Assert.Equal(0, lhs.IntroStart);
|
Assert.Equal(0, lhs.IntroStart);
|
||||||
Assert.Equal(17.792, lhs.IntroEnd);
|
Assert.Equal(17.208, lhs.IntroEnd, 3);
|
||||||
|
|
||||||
Assert.True(rhs.Valid);
|
Assert.True(rhs.Valid);
|
||||||
Assert.Equal(5.12, rhs.IntroStart);
|
// because we changed for 0.128 to 0.1238 its 4,952 now but that's too early (<= 5)
|
||||||
Assert.Equal(22.912, rhs.IntroEnd);
|
Assert.Equal(0, rhs.IntroStart);
|
||||||
|
Assert.Equal(22.1602, rhs.IntroEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
@ -13,7 +16,7 @@ public class TestBlackFrames
|
|||||||
var range = 1e-5;
|
var range = 1e-5;
|
||||||
|
|
||||||
var expected = new List<BlackFrame>();
|
var expected = new List<BlackFrame>();
|
||||||
expected.AddRange(CreateFrameSequence(2, 3));
|
expected.AddRange(CreateFrameSequence(2.04, 3));
|
||||||
expected.AddRange(CreateFrameSequence(5, 6));
|
expected.AddRange(CreateFrameSequence(5, 6));
|
||||||
expected.AddRange(CreateFrameSequence(8, 9.96));
|
expected.AddRange(CreateFrameSequence(8, 9.96));
|
||||||
|
|
||||||
@ -30,14 +33,15 @@ public class TestBlackFrames
|
|||||||
[FactSkipFFmpegTests]
|
[FactSkipFFmpegTests]
|
||||||
public void TestEndCreditDetection()
|
public void TestEndCreditDetection()
|
||||||
{
|
{
|
||||||
var range = 1;
|
// new strategy new range
|
||||||
|
var range = 3;
|
||||||
|
|
||||||
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, AnalysisMode.Credits, 85);
|
var result = analyzer.AnalyzeMediaFile(episode, 240, 30, 85);
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.InRange(result.IntroStart, 300 - range, 300 + range);
|
Assert.InRange(result.IntroStart, 300 - range, 300 + range);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||||
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
@ -17,12 +20,23 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
|||||||
|
|
||||||
private readonly ILogger<BlackFrameAnalyzer> _logger;
|
private readonly ILogger<BlackFrameAnalyzer> _logger;
|
||||||
|
|
||||||
|
private int minimumCreditsDuration;
|
||||||
|
|
||||||
|
private int maximumCreditsDuration;
|
||||||
|
|
||||||
|
private int blackFrameMinimumPercentage;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="BlackFrameAnalyzer"/> class.
|
/// Initializes a new instance of the <see cref="BlackFrameAnalyzer"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="logger">Logger.</param>
|
/// <param name="logger">Logger.</param>
|
||||||
public BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger)
|
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;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,6 +53,12 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
|||||||
|
|
||||||
var creditTimes = new Dictionary<Guid, Intro>();
|
var creditTimes = new Dictionary<Guid, Intro>();
|
||||||
|
|
||||||
|
bool isFirstEpisode = true;
|
||||||
|
|
||||||
|
double searchStart = minimumCreditsDuration;
|
||||||
|
|
||||||
|
var searchDistance = 2 * minimumCreditsDuration;
|
||||||
|
|
||||||
foreach (var episode in analysisQueue)
|
foreach (var episode in analysisQueue)
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested)
|
if (cancellationToken.IsCancellationRequested)
|
||||||
@ -46,16 +66,54 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
|||||||
break;
|
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(
|
var intro = AnalyzeMediaFile(
|
||||||
episode,
|
episode,
|
||||||
mode,
|
searchStart,
|
||||||
Plugin.Instance!.Configuration.BlackFrameMinimumPercentage);
|
searchDistance,
|
||||||
|
blackFrameMinimumPercentage);
|
||||||
|
|
||||||
if (intro is null)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchStart = episode.Duration - intro.IntroStart + (0.5 * searchDistance);
|
||||||
|
|
||||||
creditTimes[episode.EpisodeId] = intro;
|
creditTimes[episode.EpisodeId] = intro;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,16 +129,17 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
|||||||
/// Analyzes an individual media file. Only public because of unit tests.
|
/// Analyzes an individual media file. Only public because of unit tests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="episode">Media file to analyze.</param>
|
/// <param name="episode">Media file to analyze.</param>
|
||||||
/// <param name="mode">Analysis mode.</param>
|
/// <param name="searchStart">Search Start Piont.</param>
|
||||||
|
/// <param name="searchDistance">Search Distance.</param>
|
||||||
/// <param name="minimum">Percentage of the frame that must be black.</param>
|
/// <param name="minimum">Percentage of the frame that must be black.</param>
|
||||||
/// <returns>Credits timestamp.</returns>
|
/// <returns>Credits timestamp.</returns>
|
||||||
public Intro? AnalyzeMediaFile(QueuedEpisode episode, AnalysisMode mode, int minimum)
|
public Intro? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int searchDistance, int minimum)
|
||||||
{
|
{
|
||||||
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
|
|
||||||
|
|
||||||
// Start by analyzing the last N minutes of the file.
|
// Start by analyzing the last N minutes of the file.
|
||||||
var start = TimeSpan.FromSeconds(config.MaximumEpisodeCreditsDuration);
|
var upperLimit = searchStart;
|
||||||
var end = TimeSpan.FromSeconds(config.MinimumCreditsDuration);
|
var lowerLimit = Math.Max(searchStart - searchDistance, minimumCreditsDuration);
|
||||||
|
var start = TimeSpan.FromSeconds(upperLimit);
|
||||||
|
var end = TimeSpan.FromSeconds(lowerLimit);
|
||||||
var firstFrameTime = 0.0;
|
var firstFrameTime = 0.0;
|
||||||
|
|
||||||
// Continue bisecting the end of the file until the range that contains the first black
|
// Continue bisecting the end of the file until the range that contains the first black
|
||||||
@ -111,13 +170,29 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
|||||||
if (frames.Length == 0)
|
if (frames.Length == 0)
|
||||||
{
|
{
|
||||||
// Since no black frames were found, slide the range closer to the end
|
// Since no black frames were found, slide the range closer to the end
|
||||||
start = midpoint;
|
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
|
else
|
||||||
{
|
{
|
||||||
// Some black frames were found, slide the range closer to the start
|
// Some black frames were found, slide the range closer to the start
|
||||||
end = midpoint;
|
end = midpoint;
|
||||||
firstFrameTime = frames[0].Time + scanTime;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
@ -91,10 +94,12 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
|
|||||||
|
|
||||||
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
|
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
|
||||||
|
|
||||||
var minDuration = config.MinimumIntroDuration;
|
var minDuration = mode == AnalysisMode.Introduction ?
|
||||||
|
config.MinimumIntroDuration :
|
||||||
|
config.MinimumCreditsDuration;
|
||||||
int maxDuration = mode == AnalysisMode.Introduction ?
|
int maxDuration = mode == AnalysisMode.Introduction ?
|
||||||
config.MaximumIntroDuration :
|
config.MaximumIntroDuration :
|
||||||
config.MaximumEpisodeCreditsDuration;
|
config.MaximumCreditsDuration;
|
||||||
|
|
||||||
if (chapters.Count == 0)
|
if (chapters.Count == 0)
|
||||||
{
|
{
|
||||||
@ -111,10 +116,10 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check all chapters in reverse order, skipping the virtual chapter
|
// Check all chapters in reverse order, skipping the virtual chapter
|
||||||
for (int i = chapters.Count - 2; i >= 0; i--)
|
for (int i = chapters.Count - 2; i > 0; i--)
|
||||||
{
|
{
|
||||||
var current = chapters[i];
|
var current = chapters[i];
|
||||||
var next = chapters[i + 1];
|
var previous = chapters[i - 1];
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(current.Name))
|
if (string.IsNullOrWhiteSpace(current.Name))
|
||||||
{
|
{
|
||||||
@ -123,7 +128,7 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
|
|||||||
|
|
||||||
var currentRange = new TimeRange(
|
var currentRange = new TimeRange(
|
||||||
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
|
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
|
||||||
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
|
TimeSpan.FromTicks(chapters[i + 1].StartPositionTicks).TotalSeconds);
|
||||||
|
|
||||||
var baseMessage = string.Format(
|
var baseMessage = string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
@ -153,6 +158,21 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
|
|||||||
continue;
|
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);
|
matchingChapter = new(episode.EpisodeId, currentRange);
|
||||||
_logger.LogTrace("{Base}: okay", baseMessage);
|
_logger.LogTrace("{Base}: okay", baseMessage);
|
||||||
break;
|
break;
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
@ -16,7 +19,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
|||||||
/// 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.128;
|
private const double SamplesToSeconds = 0.1238;
|
||||||
|
|
||||||
private int minimumIntroDuration;
|
private int minimumIntroDuration;
|
||||||
|
|
||||||
@ -75,6 +78,12 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
|||||||
{
|
{
|
||||||
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode);
|
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode);
|
||||||
|
|
||||||
|
// Use reversed fingerprints for credits
|
||||||
|
if (_analysisMode == AnalysisMode.Credits)
|
||||||
|
{
|
||||||
|
Array.Reverse(fingerprintCache[episode.EpisodeId]);
|
||||||
|
}
|
||||||
|
|
||||||
if (cancellationToken.IsCancellationRequested)
|
if (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return analysisQueue;
|
return analysisQueue;
|
||||||
@ -123,14 +132,20 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
|||||||
* While this is desired behavior for detecting introductions, it breaks credit
|
* While this is desired behavior for detecting introductions, it breaks credit
|
||||||
* detection, as the audio we're analyzing was extracted from some point into the file.
|
* detection, as the audio we're analyzing was extracted from some point into the file.
|
||||||
*
|
*
|
||||||
* To fix this, add the starting time of the fingerprint to the reported time range.
|
* 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.
|
||||||
*/
|
*/
|
||||||
if (this._analysisMode == AnalysisMode.Credits)
|
if (this._analysisMode == AnalysisMode.Credits)
|
||||||
{
|
{
|
||||||
currentIntro.IntroStart += currentEpisode.CreditsFingerprintStart;
|
// Calculate new values for the current intro
|
||||||
currentIntro.IntroEnd += currentEpisode.CreditsFingerprintStart;
|
double currentOriginalIntroStart = currentIntro.IntroStart;
|
||||||
remainingIntro.IntroStart += remainingEpisode.CreditsFingerprintStart;
|
currentIntro.IntroStart = currentEpisode.Duration - currentIntro.IntroEnd;
|
||||||
remainingIntro.IntroEnd += remainingEpisode.CreditsFingerprintStart;
|
currentIntro.IntroEnd = currentEpisode.Duration - currentOriginalIntroStart;
|
||||||
|
|
||||||
|
// Calculate new values for the remaining intro
|
||||||
|
double remainingIntroOriginalStart = remainingIntro.IntroStart;
|
||||||
|
remainingIntro.IntroStart = remainingEpisode.Duration - remainingIntro.IntroEnd;
|
||||||
|
remainingIntro.IntroEnd = remainingEpisode.Duration - remainingIntroOriginalStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only save the discovered intro if it is:
|
// Only save the discovered intro if it is:
|
||||||
@ -154,7 +169,10 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If no intro is found at this point, the popped episode is not reinserted into the queue.
|
// If no intro is found at this point, the popped episode is not reinserted into the queue.
|
||||||
episodesWithoutIntros.Add(currentEpisode);
|
if (!seasonIntros.ContainsKey(currentEpisode.EpisodeId))
|
||||||
|
{
|
||||||
|
episodesWithoutIntros.Add(currentEpisode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If cancellation was requested, report that no episodes were analyzed.
|
// If cancellation was requested, report that no episodes were analyzed.
|
||||||
@ -261,8 +279,8 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
|||||||
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 = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints);
|
var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, this._analysisMode);
|
||||||
var rhsIndex = FFmpegWrapper.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.
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@ -109,7 +112,7 @@ public class AutoSkip : IServerEntryPoint
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 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)
|
if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
|
||||||
{
|
{
|
||||||
newState = true;
|
newState = true;
|
||||||
}
|
}
|
||||||
@ -149,15 +152,16 @@ public class AutoSkip : IServerEntryPoint
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Seek is unreliable if called at the very start of an episode.
|
// Seek is unreliable if called at the very start of an episode.
|
||||||
var adjustedStart = Math.Max(5, intro.IntroStart);
|
var adjustedStart = Math.Max(5, intro.IntroStart + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay);
|
||||||
|
var adjustedEnd = intro.IntroEnd - Plugin.Instance.Configuration.RemainingSecondsOfIntro;
|
||||||
|
|
||||||
_logger.LogTrace(
|
_logger.LogTrace(
|
||||||
"Playback position is {Position}, intro runs from {Start} to {End}",
|
"Playback position is {Position}, intro runs from {Start} to {End}",
|
||||||
position,
|
position,
|
||||||
adjustedStart,
|
adjustedStart,
|
||||||
intro.IntroEnd);
|
adjustedEnd);
|
||||||
|
|
||||||
if (position < adjustedStart || position > intro.IntroEnd)
|
if (position < adjustedStart || position > adjustedEnd)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -180,8 +184,6 @@ public class AutoSkip : IServerEntryPoint
|
|||||||
|
|
||||||
_logger.LogDebug("Sending seek command to {Session}", deviceId);
|
_logger.LogDebug("Sending seek command to {Session}", deviceId);
|
||||||
|
|
||||||
var introEnd = (long)intro.IntroEnd - Plugin.Instance!.Configuration.SecondsOfIntroToPlay;
|
|
||||||
|
|
||||||
_sessionManager.SendPlaystateCommand(
|
_sessionManager.SendPlaystateCommand(
|
||||||
session.Id,
|
session.Id,
|
||||||
session.Id,
|
session.Id,
|
||||||
@ -189,7 +191,7 @@ public class AutoSkip : IServerEntryPoint
|
|||||||
{
|
{
|
||||||
Command = PlaystateCommand.Seek,
|
Command = PlaystateCommand.Seek,
|
||||||
ControllingUserId = session.UserId.ToString("N"),
|
ControllingUserId = session.UserId.ToString("N"),
|
||||||
SeekPositionTicks = introEnd * TimeSpan.TicksPerSecond,
|
SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond,
|
||||||
},
|
},
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,7 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using MediaBrowser.Model.Plugins;
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
@ -27,6 +31,21 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string SelectedLibraries { get; set; } = string.Empty;
|
public string SelectedLibraries { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a temporary limitation on file paths to be analyzed. Should be empty when automatic scan is idle.
|
||||||
|
/// </summary>
|
||||||
|
public IList<string> PathRestrictions { get; } = new List<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to scan for intros during a scheduled task.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoDetectIntros { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to scan for credits during a scheduled task.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoDetectCredits { get; set; } = false;
|
||||||
|
|
||||||
/// <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>
|
||||||
@ -86,7 +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 MaximumEpisodeCreditsDuration { get; set; } = 240;
|
public int MaximumCreditsDuration { get; set; } = 300;
|
||||||
|
|
||||||
/// <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.
|
||||||
@ -103,7 +122,7 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// 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)(\s|$)";
|
@"(^|\s)(Credits?|ED|Ending|End|Outro)(\s|$)";
|
||||||
|
|
||||||
// ===== Playback settings =====
|
// ===== Playback settings =====
|
||||||
|
|
||||||
@ -117,6 +136,11 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AutoSkip { get; set; }
|
public bool AutoSkip { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether credits should be automatically skipped.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoSkipCredits { 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>
|
||||||
@ -140,7 +164,17 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the amount of intro to play (in seconds).
|
/// Gets or sets the amount of intro to play (in seconds).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int SecondsOfIntroToPlay { get; set; } = 2;
|
public int RemainingSecondsOfIntro { get; set; } = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the amount of intro at start to play (in seconds).
|
||||||
|
/// </summary>
|
||||||
|
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 =====
|
||||||
|
|
||||||
@ -188,6 +222,11 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string AutoSkipNotificationText { get; set; } = "Intro skipped";
|
public string AutoSkipNotificationText { get; set; } = "Intro skipped";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the notification text sent after automatically skipping credits.
|
||||||
|
/// </summary>
|
||||||
|
public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the number of threads for an ffmpeg process.
|
/// Gets or sets the number of threads for an ffmpeg process.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
||||||
data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
data-require="emby-input,emby-button,emby-select,emby-checkbox,emby-linkbutton">
|
||||||
<div data-role="content">
|
<div data-role="content">
|
||||||
<style>
|
<style>
|
||||||
summary {
|
summary {
|
||||||
@ -27,6 +27,31 @@
|
|||||||
<fieldset class="verticalSection-extrabottompadding">
|
<fieldset class="verticalSection-extrabottompadding">
|
||||||
<legend>Analysis</legend>
|
<legend>Analysis</legend>
|
||||||
|
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="AutoDetectIntros" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Automatically Scan Intros</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="fieldDescription">
|
||||||
|
If enabled, introductions will be automatically analyzed for new media
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="AutoDetectCredits" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Automatically Scan Credits</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="fieldDescription">
|
||||||
|
If enabled, credits will be automatically analyzed for new media
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Note: Not selecting at least one automatic detection type will disable automatic scans. To configure the scheduled task, see <a is="emby-linkbutton" class="button-link" href="scheduledtasks.html">scheduled tasks</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
<label class="emby-checkbox-label">
|
<label class="emby-checkbox-label">
|
||||||
<input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" />
|
<input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" />
|
||||||
@ -36,6 +61,7 @@
|
|||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
If checked, season 0 (specials / extras) will be included in analysis.
|
If checked, season 0 (specials / extras) will be included in analysis.
|
||||||
<br />
|
<br />
|
||||||
|
<br />
|
||||||
Note: Shows containing both a specials and extra folder will identify extras as season 0
|
Note: Shows containing both a specials and extra folder will identify extras as season 0
|
||||||
and ignore specials, regardless of this setting.
|
and ignore specials, regardless of this setting.
|
||||||
</div>
|
</div>
|
||||||
@ -63,7 +89,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details id="intro_reqs">
|
<details id="intro_reqs">
|
||||||
<summary>Modify Intro Parameters</summary>
|
<summary>Modify Segment Parameters</summary>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
@ -111,6 +137,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="MinimumCreditsDuration">
|
||||||
|
Minimum credits duration (in seconds)
|
||||||
|
</label>
|
||||||
|
<input id="MinimumCreditsDuration" type="number" is="emby-input" min="1" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Similar sounding audio which is shorter than this duration will not be considered credits.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="MaximumCreditsDuration">
|
||||||
|
Maximum credits duration (in seconds)
|
||||||
|
</label>
|
||||||
|
<input id="MaximumCreditsDuration" type="number" is="emby-input" min="1" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Similar sounding audio which is longer than this duration will not be considered credits.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
The amount of each episode's audio that will be analyzed is determined using both
|
The amount of each episode's audio that will be analyzed is determined using both
|
||||||
the percentage of audio and maximum runtime of audio to analyze. The minimum of
|
the percentage of audio and maximum runtime of audio to analyze. The minimum of
|
||||||
@ -121,7 +167,7 @@
|
|||||||
<p>
|
<p>
|
||||||
If the audio percentage or maximum runtime settings are modified, the cached
|
If the audio percentage or maximum runtime settings are modified, the cached
|
||||||
fingerprints and introduction timestamps for each season you want to analyze with the
|
fingerprints and introduction timestamps for each season you want to analyze with the
|
||||||
modified settings <strong>will have to be deleted.</strong>
|
modified settings <b>will have to be deleted.</b>
|
||||||
|
|
||||||
Increasing either of these settings will cause episode analysis to take much longer.
|
Increasing either of these settings will cause episode analysis to take much longer.
|
||||||
</p>
|
</p>
|
||||||
@ -147,7 +193,7 @@
|
|||||||
</option>
|
</option>
|
||||||
|
|
||||||
<option value="Intro">
|
<option value="Intro">
|
||||||
Intro (show a skip button, *experimental*)
|
Intro/Credit (show a skip button, *experimental*)
|
||||||
</option>
|
</option>
|
||||||
|
|
||||||
<option value="Mute">
|
<option value="Mute">
|
||||||
@ -176,8 +222,8 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
If checked, the plugin will <strong>overwrite all EDL files</strong> associated with
|
If checked, the plugin will <b>overwrite all EDL files</b> associated with
|
||||||
your episodes with the currently discovered introduction timestamps and EDL action.
|
your episodes with the currently discovered introduction/credit timestamps and EDL action.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@ -191,7 +237,7 @@
|
|||||||
Noise tolerance
|
Noise tolerance
|
||||||
</label>
|
</label>
|
||||||
<input id="SilenceDetectionMaximumNoise" type="number" is="emby-input" min="-90"
|
<input id="SilenceDetectionMaximumNoise" type="number" is="emby-input" min="-90"
|
||||||
max="0" />
|
max="0" />
|
||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
Noise tolerance in negative decibels.
|
Noise tolerance in negative decibels.
|
||||||
</div>
|
</div>
|
||||||
@ -202,7 +248,7 @@
|
|||||||
Minimum silence duration
|
Minimum silence duration
|
||||||
</label>
|
</label>
|
||||||
<input id="SilenceDetectionMinimumDuration" type="number" is="emby-input" min="0"
|
<input id="SilenceDetectionMinimumDuration" type="number" is="emby-input" min="0"
|
||||||
step="0.01" />
|
step="0.01" />
|
||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
Minimum silence duration in seconds before adjusting introduction end time.
|
Minimum silence duration in seconds before adjusting introduction end time.
|
||||||
</div>
|
</div>
|
||||||
@ -212,7 +258,7 @@
|
|||||||
<details id="detection">
|
<details id="detection">
|
||||||
<summary>Process Configuration</summary>
|
<summary>Process Configuration</summary>
|
||||||
|
|
||||||
<br/>
|
<br />
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
<label class="emby-checkbox-label">
|
<label class="emby-checkbox-label">
|
||||||
<input id="UseChromaprint" type="checkbox" is="emby-checkbox" />
|
<input id="UseChromaprint" type="checkbox" is="emby-checkbox" />
|
||||||
@ -222,7 +268,7 @@
|
|||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
If checked, analysis will use Chromaprint to compare episode audio and identify intros.
|
If checked, analysis will use Chromaprint to compare episode audio and identify intros.
|
||||||
<br />
|
<br />
|
||||||
<strong>WARNING: Disabling this option may result in incomplete or innaccurate analysis!</strong>
|
<b>WARNING: Disabling this option may result in incomplete or innaccurate analysis!</b>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -236,7 +282,7 @@
|
|||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
If checked, episode fingerprints will be saved on the filesystem to improve analysis speed.
|
If checked, episode fingerprints will be saved on the filesystem to improve analysis speed.
|
||||||
<br />
|
<br />
|
||||||
<strong>WARNING: May result in lengthy detection! Not recommended for large libraries!</strong>
|
<b>WARNING: Disabling the cache will cause all libraries to be re-scanned, which can take a very long time!</b>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -280,7 +326,7 @@
|
|||||||
ffmpeg Threads
|
ffmpeg Threads
|
||||||
</label>
|
</label>
|
||||||
<input id="ProcessThreads" type="number" is="emby-input" min="0"
|
<input id="ProcessThreads" type="number" is="emby-input" min="0"
|
||||||
max="16" />
|
max="16" />
|
||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
Number of simultaneous processes to use for ffmpeg operations.
|
Number of simultaneous processes to use for ffmpeg operations.
|
||||||
<br />
|
<br />
|
||||||
@ -310,12 +356,48 @@
|
|||||||
<div id="divSkipFirstEpisode" class="checkboxContainer checkboxContainer-withDescription">
|
<div id="divSkipFirstEpisode" class="checkboxContainer checkboxContainer-withDescription">
|
||||||
<label class="emby-checkbox-label">
|
<label class="emby-checkbox-label">
|
||||||
<input id="SkipFirstEpisode" type="checkbox" is="emby-checkbox" />
|
<input id="SkipFirstEpisode" type="checkbox" is="emby-checkbox" />
|
||||||
<span>Ignore intro in the first episode of a season</span>
|
<span>Play intro for first episode of a season</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
If checked, auto skip will ignore introduction in the first episode of a season.<br />
|
If checked, auto skip will play the introduction of the first episode in a season.<br />
|
||||||
</div>
|
</div>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="divSecondsOfIntroStartToPlay" class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SecondsOfIntroStartToPlay">
|
||||||
|
Intro playback duration (in seconds)
|
||||||
|
</label>
|
||||||
|
<input id="SecondsOfIntroStartToPlay" type="number" is="emby-input" min="0" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Seconds of introduction start that should be played. Defaults to 0.
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="AutoSkipCredits" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Automatically skip credits</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="fieldDescription">
|
||||||
|
If checked, credits will be automatically skipped. If you access Jellyfin through a
|
||||||
|
reverse proxy, it must be configured to proxy web
|
||||||
|
sockets.<br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="divSecondsOfCreditsStartToPlay" class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SecondsOfCreditsStartToPlay">
|
||||||
|
Credit playback duration (in seconds)
|
||||||
|
</label>
|
||||||
|
<input id="SecondsOfCreditsStartToPlay" type="number" is="emby-input" min="0" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Seconds of credits start that should be played. Defaults to 0.
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
@ -326,7 +408,7 @@
|
|||||||
|
|
||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
If checked, a skip button will be displayed at the start of an episode's introduction.
|
If checked, a skip button will be displayed at the start of an episode's introduction.
|
||||||
<strong>Only applies to the web interface and compatible applications.</strong>
|
<b>Only applies to the web interface and compatible applications.</b>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -338,7 +420,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
If checked, skip button will remain visible throught the intro (offset and timeout are ignored).
|
If checked, skip button will remain visible for the entire intro (offset and timeout are ignored).
|
||||||
<br />
|
<br />
|
||||||
Note: If unchecked, button will only appear in the player controls after the set timeout.
|
Note: If unchecked, button will only appear in the player controls after the set timeout.
|
||||||
</div>
|
</div>
|
||||||
@ -353,6 +435,7 @@
|
|||||||
Seconds to display skip prompt before introduction begins.
|
Seconds to display skip prompt before introduction begins.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<br />
|
||||||
|
|
||||||
<div id="divHidePromptAdjustment" class="inputContainer">
|
<div id="divHidePromptAdjustment" class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="HidePromptAdjustment">
|
<label class="inputLabel inputLabelUnfocused" for="HidePromptAdjustment">
|
||||||
@ -363,12 +446,13 @@
|
|||||||
Seconds after introduction before skip prompt is hidden.
|
Seconds after introduction before skip prompt is hidden.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<br />
|
||||||
|
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="SecondsOfIntroToPlay">
|
<label class="inputLabel inputLabelUnfocused" for="RemainingSecondsOfIntro">
|
||||||
Intro playback duration (in seconds)
|
Intro playback duration (in seconds)
|
||||||
</label>
|
</label>
|
||||||
<input id="SecondsOfIntroToPlay" type="number" is="emby-input" min="0" />
|
<input id="RemainingSecondsOfIntro" type="number" is="emby-input" min="0" />
|
||||||
<div class="fieldDescription">
|
<div class="fieldDescription">
|
||||||
Seconds of introduction ending that should be played. Defaults to 2.
|
Seconds of introduction ending that should be played. Defaults to 2.
|
||||||
</div>
|
</div>
|
||||||
@ -407,6 +491,16 @@
|
|||||||
Message shown after automatically skipping an introduction. Leave blank to disable notification.
|
Message shown after automatically skipping an introduction. Leave blank to disable notification.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="divAutoSkipCreditsNotificationText" class="inputContainer">
|
||||||
|
<label class="inputLabel" for="AutoSkipCreditsNotificationText">
|
||||||
|
Automatic skip notification message
|
||||||
|
</label>
|
||||||
|
<input id="AutoSkipCreditsNotificationText" type="text" is="emby-input" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Message shown after automatically skipping credits. Leave blank to disable notification.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@ -431,32 +525,54 @@
|
|||||||
<summary>Manage Fingerprints</summary>
|
<summary>Manage Fingerprints</summary>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
<h3 style="margin:0">Select episodes to manage</h3>
|
<label class="inputLabel" for="troubleshooterShow">Select TV Series to manage</label>
|
||||||
<br />
|
<select is="emby-select" id="troubleshooterShow" class="emby-select-withcolor emby-select"></select>
|
||||||
<select id="troubleshooterShow"></select>
|
<label class="inputLabel" for="troubleshooterSeason">Select Season to manage</label>
|
||||||
<select id="troubleshooterSeason"></select>
|
<select is="emby-select" id="troubleshooterSeason" class="emby-select-withcolor emby-select"></select>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<select id="troubleshooterEpisode1"></select>
|
<label class="inputLabel" for="troubleshooterEpisode1">Select the first episode</label>
|
||||||
<select id="troubleshooterEpisode2"></select>
|
<select is="emby-select" id="troubleshooterEpisode1" class="emby-select-withcolor emby-select"></select>
|
||||||
<br />
|
<label class="inputLabel" for="troubleshooterEpisode1">Select the second episode</label>
|
||||||
|
<select is="emby-select" id="troubleshooterEpisode2" class="emby-select-withcolor emby-select"></select>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<div id="timestampEditor" style="display:none">
|
<div id="timestampEditor" style="display:none">
|
||||||
<h3 style="margin:0">Introduction timestamp editor</h3>
|
<h3 style="margin:0">Introduction timestamp editor</h3>
|
||||||
<p style="margin:0">All times are in seconds.</p>
|
<p style="margin:0">All times are displayed in the format (HH:MM:SS)</p>
|
||||||
|
|
||||||
<p id="editLeftEpisodeTitle" style="margin-bottom:0"></p>
|
|
||||||
<input style="width:4em" type="number" min="0" id="editLeftEpisodeStart"> to
|
|
||||||
<input style="width:4em;margin-bottom:10px" type="number" min="0" id="editLeftEpisodeEnd">
|
|
||||||
|
|
||||||
<p id="editRightEpisodeTitle" style="margin-top:0;margin-bottom:0"></p>
|
|
||||||
<input style="width:4em" type="number" min="0" id="editRightEpisodeStart"> to
|
|
||||||
<input style="width:4em;margin-bottom:10px" type="number" min="0" id="editRightEpisodeEnd">
|
|
||||||
<br />
|
<br />
|
||||||
|
<table class="detailTable">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="detailTableHeaderCell">Episode</th>
|
||||||
|
<th scope="col" class="detailTableHeaderCell">Section</th>
|
||||||
|
<th scope="col" class="detailTableHeaderCell">Start Time</th>
|
||||||
|
<th scope="col" class="detailTableHeaderCell">End Time</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td rowspan="2" id="editLeftEpisodeTitle"></td>
|
||||||
|
<td>Intro</td>
|
||||||
|
<td><input type="time" step="1" id="editLeftIntroEpisodeStart"></td>
|
||||||
|
<td><input type="time" step="1" id="editLeftIntroEpisodeEnd"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Credits</td>
|
||||||
|
<td><input type="time" step="1" id="editLeftCreditEpisodeStart"></td>
|
||||||
|
<td><input type="time" step="1" id="editLeftCreditEpisodeEnd"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td rowspan="2" id="editRightEpisodeTitle"></td>
|
||||||
|
<td>Intro</td>
|
||||||
|
<td><input type="time" step="1" id="editRightIntroEpisodeStart"></td>
|
||||||
|
<td><input type="time" step="1" id="editRightIntroEpisodeEnd"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Credits</td>
|
||||||
|
<td><input type="time" step="1" id="editRightCreditEpisodeStart"></td>
|
||||||
|
<td><input type="time" step="1" id="editRightCreditEpisodeEnd"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
<br />
|
<br />
|
||||||
|
<button is="emby-select" id="btnUpdateTimestamps" class="raised button-submit block emby-button" type="button">
|
||||||
<button id="btnUpdateTimestamps" type="button">
|
|
||||||
Update timestamps
|
Update timestamps
|
||||||
</button>
|
</button>
|
||||||
<br />
|
<br />
|
||||||
@ -476,21 +592,23 @@
|
|||||||
</p>
|
</p>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<td style="min-width: 100px; font-weight: bold">Key</td>
|
<tr>
|
||||||
<td style="font-weight: bold">Function</td>
|
<td style="min-width: 100px; font-weight: bold">Key</td>
|
||||||
|
<td style="font-weight: bold">Function</td>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Up arrow</td>
|
<td>Up arrow</td>
|
||||||
<td>
|
<td>
|
||||||
Shift the left episode up by 0.128 seconds.
|
Shift the left episode up by 0.1238 seconds.
|
||||||
Holding control will shift the episode by 10 seconds.
|
Holding control will shift the episode by 10 seconds.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Down arrow</td>
|
<td>Down arrow</td>
|
||||||
<td>
|
<td>
|
||||||
Shift the left episode down by 0.128 seconds.
|
Shift the left episode down by 0.1238 seconds.
|
||||||
Holding control will shift the episode by 10 seconds.
|
Holding control will shift the episode by 10 seconds.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -568,28 +686,36 @@
|
|||||||
"AnalysisLengthLimit",
|
"AnalysisLengthLimit",
|
||||||
"MinimumIntroDuration",
|
"MinimumIntroDuration",
|
||||||
"MaximumIntroDuration",
|
"MaximumIntroDuration",
|
||||||
|
"MinimumCreditsDuration",
|
||||||
|
"MaximumCreditsDuration",
|
||||||
"EdlAction",
|
"EdlAction",
|
||||||
"ProcessPriority",
|
"ProcessPriority",
|
||||||
"ProcessThreads",
|
"ProcessThreads",
|
||||||
// playback
|
// playback
|
||||||
"ShowPromptAdjustment",
|
"ShowPromptAdjustment",
|
||||||
"HidePromptAdjustment",
|
"HidePromptAdjustment",
|
||||||
"SecondsOfIntroToPlay",
|
"RemainingSecondsOfIntro",
|
||||||
|
"SecondsOfIntroStartToPlay",
|
||||||
|
"SecondsOfCreditsStartToPlay",
|
||||||
// internals
|
// internals
|
||||||
"SilenceDetectionMaximumNoise",
|
"SilenceDetectionMaximumNoise",
|
||||||
"SilenceDetectionMinimumDuration",
|
"SilenceDetectionMinimumDuration",
|
||||||
// UI customization
|
// UI customization
|
||||||
"SkipButtonIntroText",
|
"SkipButtonIntroText",
|
||||||
"SkipButtonEndCreditsText",
|
"SkipButtonEndCreditsText",
|
||||||
"AutoSkipNotificationText"
|
"AutoSkipNotificationText",
|
||||||
|
"AutoSkipCreditsNotificationText"
|
||||||
]
|
]
|
||||||
|
|
||||||
var booleanConfigurationFields = [
|
var booleanConfigurationFields = [
|
||||||
|
"AutoDetectIntros",
|
||||||
|
"AutoDetectCredits",
|
||||||
"AnalyzeSeasonZero",
|
"AnalyzeSeasonZero",
|
||||||
"RegenerateEdlFiles",
|
"RegenerateEdlFiles",
|
||||||
"UseChromaprint",
|
"UseChromaprint",
|
||||||
"CacheFingerprints",
|
"CacheFingerprints",
|
||||||
"AutoSkip",
|
"AutoSkip",
|
||||||
|
"AutoSkipCredits",
|
||||||
"SkipFirstEpisode",
|
"SkipFirstEpisode",
|
||||||
"PersistSkipButton",
|
"PersistSkipButton",
|
||||||
"SkipButtonVisible"
|
"SkipButtonVisible"
|
||||||
@ -613,20 +739,38 @@
|
|||||||
|
|
||||||
var autoSkip = document.querySelector("input#AutoSkip");
|
var autoSkip = document.querySelector("input#AutoSkip");
|
||||||
var skipFirstEpisode = document.querySelector("div#divSkipFirstEpisode");
|
var skipFirstEpisode = document.querySelector("div#divSkipFirstEpisode");
|
||||||
|
var secondsOfIntroStartToPlay = document.querySelector("div#divSecondsOfIntroStartToPlay");
|
||||||
|
var secondsOfCreditsStartToPlay = document.querySelector("div#divSecondsOfCreditsStartToPlay");
|
||||||
var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText");
|
var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText");
|
||||||
|
var autoSkipCredits = document.querySelector("input#AutoSkipCredits");
|
||||||
|
var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText");
|
||||||
|
|
||||||
async function autoSkipChanged() {
|
async function autoSkipChanged() {
|
||||||
if (autoSkip.checked) {
|
if (autoSkip.checked) {
|
||||||
skipFirstEpisode.style.display = 'unset';
|
skipFirstEpisode.style.display = 'unset';
|
||||||
autoSkipNotificationText.style.display = 'unset';
|
autoSkipNotificationText.style.display = 'unset';
|
||||||
|
secondsOfIntroStartToPlay.style.display = 'unset';
|
||||||
} else {
|
} else {
|
||||||
skipFirstEpisode.style.display = 'none';
|
skipFirstEpisode.style.display = 'none';
|
||||||
autoSkipNotificationText.style.display = 'none';
|
autoSkipNotificationText.style.display = 'none';
|
||||||
|
secondsOfIntroStartToPlay.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
autoSkip.addEventListener("change", autoSkipChanged);
|
autoSkip.addEventListener("change", autoSkipChanged);
|
||||||
|
|
||||||
|
async function autoSkipCreditsChanged() {
|
||||||
|
if (autoSkipCredits.checked) {
|
||||||
|
autoSkipCreditsNotificationText.style.display = 'unset';
|
||||||
|
secondsOfCreditsStartToPlay.style.display = 'unset';
|
||||||
|
} else {
|
||||||
|
autoSkipCreditsNotificationText.style.display = 'none';
|
||||||
|
secondsOfCreditsStartToPlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
autoSkipCredits.addEventListener("change", autoSkipCreditsChanged);
|
||||||
|
|
||||||
var persistSkip = document.querySelector("input#PersistSkipButton");
|
var persistSkip = document.querySelector("input#PersistSkipButton");
|
||||||
var showAdjustment = document.querySelector("div#divShowPromptAdjustment");
|
var showAdjustment = document.querySelector("div#divShowPromptAdjustment");
|
||||||
var hideAdjustment = document.querySelector("div#divHidePromptAdjustment");
|
var hideAdjustment = document.querySelector("div#divHidePromptAdjustment");
|
||||||
@ -782,27 +926,24 @@
|
|||||||
// Get the title and ID of the left and right episodes
|
// Get the title and ID of the left and right episodes
|
||||||
const leftEpisode = selectEpisode1.options[selectEpisode1.selectedIndex];
|
const leftEpisode = selectEpisode1.options[selectEpisode1.selectedIndex];
|
||||||
const rightEpisode = selectEpisode2.options[selectEpisode2.selectedIndex];
|
const rightEpisode = selectEpisode2.options[selectEpisode2.selectedIndex];
|
||||||
|
|
||||||
// Try to get the timestamps of each intro, falling back a default value of zero if no intro was found
|
// Try to get the timestamps of each intro, falling back a default value of zero if no intro was found
|
||||||
let leftEpisodeIntro = await getJson("Episode/" + leftEpisode.value + "/IntroTimestamps/v1");
|
const leftEpisodeJson = await getJson("Episode/" + leftEpisode.value + "/Timestamps");
|
||||||
if (leftEpisodeIntro === null) {
|
const rightEpisodeJson = await getJson("Episode/" + rightEpisode.value + "/Timestamps");
|
||||||
leftEpisodeIntro = { IntroStart: 0, IntroEnd: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
let rightEpisodeIntro = await getJson("Episode/" + rightEpisode.value + "/IntroTimestamps/v1");
|
|
||||||
if (rightEpisodeIntro === null) {
|
|
||||||
rightEpisodeIntro = { IntroStart: 0, IntroEnd: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the editor for the first and second episodes
|
// Update the editor for the first and second episodes
|
||||||
timestampEditor.style.display = "unset";
|
timestampEditor.style.display = "unset";
|
||||||
document.querySelector("#editLeftEpisodeTitle").textContent = leftEpisode.text;
|
document.querySelector("#editLeftEpisodeTitle").textContent = leftEpisode.text;
|
||||||
document.querySelector("#editLeftEpisodeStart").value = Math.round(leftEpisodeIntro.IntroStart);
|
document.querySelector("#editLeftIntroEpisodeStart").value = setTime(Math.round(leftEpisodeJson.Introduction.IntroStart));
|
||||||
document.querySelector("#editLeftEpisodeEnd").value = Math.round(leftEpisodeIntro.IntroEnd);
|
document.querySelector("#editLeftIntroEpisodeEnd").value = setTime(Math.round(leftEpisodeJson.Introduction.IntroEnd));
|
||||||
|
document.querySelector("#editLeftCreditEpisodeStart").value = setTime(Math.round(leftEpisodeJson.Credits.IntroStart));
|
||||||
|
document.querySelector("#editLeftCreditEpisodeEnd").value = setTime(Math.round(leftEpisodeJson.Credits.IntroEnd));
|
||||||
|
|
||||||
document.querySelector("#editRightEpisodeTitle").textContent = rightEpisode.text;
|
document.querySelector("#editRightEpisodeTitle").textContent = rightEpisode.text;
|
||||||
document.querySelector("#editRightEpisodeStart").value = Math.round(rightEpisodeIntro.IntroStart);
|
document.querySelector("#editRightIntroEpisodeStart").value = setTime(Math.round(rightEpisodeJson.Introduction.IntroStart));
|
||||||
document.querySelector("#editRightEpisodeEnd").value = Math.round(rightEpisodeIntro.IntroEnd);
|
document.querySelector("#editRightIntroEpisodeEnd").value = setTime(Math.round(rightEpisodeJson.Introduction.IntroEnd));
|
||||||
|
document.querySelector("#editRightCreditEpisodeStart").value = setTime(Math.round(rightEpisodeJson.Credits.IntroStart));
|
||||||
|
document.querySelector("#editRightCreditEpisodeEnd").value = setTime(Math.round(rightEpisodeJson.Credits.IntroEnd));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// adds an item to a dropdown
|
// adds an item to a dropdown
|
||||||
@ -867,13 +1008,13 @@
|
|||||||
case "ArrowDown":
|
case "ArrowDown":
|
||||||
if (timestampError.value != "") {
|
if (timestampError.value != "") {
|
||||||
// if the control key is pressed, shift LHS by 10s. Otherwise, shift by 1.
|
// if the control key is pressed, shift LHS by 10s. Otherwise, shift by 1.
|
||||||
offsetDelta = e.ctrlKey ? 10 / 0.128 : 1;
|
offsetDelta = e.ctrlKey ? 10 / 0.1238 : 1;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
if (timestampError.value != "") {
|
if (timestampError.value != "") {
|
||||||
offsetDelta = e.ctrlKey ? -10 / 0.128 : -1;
|
offsetDelta = e.ctrlKey ? -10 / 0.1238 : -1;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -928,7 +1069,7 @@
|
|||||||
|
|
||||||
// converts seconds to a readable timestamp (i.e. 127 becomes "02:07").
|
// converts seconds to a readable timestamp (i.e. 127 becomes "02:07").
|
||||||
function secondsToString(seconds) {
|
function secondsToString(seconds) {
|
||||||
return new Date(seconds * 1000).toISOString().substr(14, 5);
|
return new Date(seconds * 1000).toISOString().slice(14, 19);
|
||||||
}
|
}
|
||||||
|
|
||||||
// erase all intro/credits timestamps
|
// erase all intro/credits timestamps
|
||||||
@ -966,6 +1107,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
autoSkipChanged();
|
autoSkipChanged();
|
||||||
|
autoSkipCreditsChanged();
|
||||||
persistSkipChanged();
|
persistSkipChanged();
|
||||||
|
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
@ -1030,19 +1172,30 @@
|
|||||||
});
|
});
|
||||||
btnUpdateTimestamps.addEventListener("click", () => {
|
btnUpdateTimestamps.addEventListener("click", () => {
|
||||||
const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value;
|
const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value;
|
||||||
const newLhsIntro = {
|
const newLhs = {
|
||||||
IntroStart: document.querySelector("#editLeftEpisodeStart").value,
|
Introduction: {
|
||||||
IntroEnd: document.querySelector("#editLeftEpisodeEnd").value,
|
IntroStart: getTimeInSeconds(document.getElementById('editLeftIntroEpisodeStart').value),
|
||||||
|
IntroEnd: getTimeInSeconds(document.getElementById('editLeftIntroEpisodeEnd').value)
|
||||||
|
},
|
||||||
|
Credits: {
|
||||||
|
IntroStart: getTimeInSeconds(document.getElementById('editLeftCreditEpisodeStart').value),
|
||||||
|
IntroEnd: getTimeInSeconds(document.getElementById('editLeftCreditEpisodeEnd').value)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rhsId = selectEpisode2.options[selectEpisode2.selectedIndex].value;
|
const rhsId = selectEpisode2.options[selectEpisode2.selectedIndex].value;
|
||||||
const newRhsIntro = {
|
const newRhs = {
|
||||||
IntroStart: document.querySelector("#editRightEpisodeStart").value,
|
Introduction: {
|
||||||
IntroEnd: document.querySelector("#editRightEpisodeEnd").value,
|
IntroStart: getTimeInSeconds(document.getElementById('editRightIntroEpisodeStart').value),
|
||||||
|
IntroEnd: getTimeInSeconds(document.getElementById('editRightIntroEpisodeEnd').value)
|
||||||
|
},
|
||||||
|
Credits: {
|
||||||
|
IntroStart: getTimeInSeconds(document.getElementById('editRightCreditEpisodeStart').value),
|
||||||
|
IntroEnd: getTimeInSeconds(document.getElementById('editRightCreditEpisodeEnd').value)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
fetchWithAuth("Episode/" + lhsId + "/Timestamps", "POST", JSON.stringify(newLhs));
|
||||||
fetchWithAuth("Intros/Episode/" + lhsId + "/UpdateIntroTimestamps", "POST", JSON.stringify(newLhsIntro));
|
fetchWithAuth("Episode/" + rhsId + "/Timestamps", "POST", JSON.stringify(newRhs));
|
||||||
fetchWithAuth("Intros/Episode/" + rhsId + "/UpdateIntroTimestamps", "POST", JSON.stringify(newRhsIntro));
|
|
||||||
|
|
||||||
Dashboard.alert("New introduction timestamps saved");
|
Dashboard.alert("New introduction timestamps saved");
|
||||||
});
|
});
|
||||||
@ -1056,12 +1209,12 @@
|
|||||||
|
|
||||||
let lTime, rTime, diffPos;
|
let lTime, rTime, diffPos;
|
||||||
if (shift < 0) {
|
if (shift < 0) {
|
||||||
lTime = y * 0.128;
|
lTime = y * 0.1238;
|
||||||
rTime = (y + shift) * 0.128;
|
rTime = (y + shift) * 0.1238;
|
||||||
diffPos = y + shift;
|
diffPos = y + shift;
|
||||||
} else {
|
} else {
|
||||||
lTime = (y - shift) * 0.128;
|
lTime = (y - shift) * 0.1238;
|
||||||
rTime = y * 0.128;
|
rTime = y * 0.1238;
|
||||||
diffPos = y - shift;
|
diffPos = y - shift;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1086,6 +1239,27 @@
|
|||||||
timeContainer.style.left = "25px";
|
timeContainer.style.left = "25px";
|
||||||
timeContainer.style.top = (-1 * rect.height + y).toString() + "px";
|
timeContainer.style.top = (-1 * rect.height + y).toString() + "px";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function setTime(seconds) {
|
||||||
|
// Calculate hours, minutes, and remaining seconds
|
||||||
|
let hours = Math.floor(seconds / 3600);
|
||||||
|
let minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
let remainingSeconds = seconds % 60;
|
||||||
|
|
||||||
|
// Format as HH:MM:SS
|
||||||
|
let formattedTime =
|
||||||
|
String(hours).padStart(2, '0') + ':' +
|
||||||
|
String(minutes).padStart(2, '0') + ':' +
|
||||||
|
String(remainingSeconds).padStart(2, '0');
|
||||||
|
|
||||||
|
// Set the value of the time input
|
||||||
|
return formattedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeInSeconds(time) {
|
||||||
|
let [hours, minutes, seconds] = time.split(':').map(Number);
|
||||||
|
return (hours * 3600) + (minutes * 60) + seconds;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
@ -6,105 +6,106 @@ let introSkipper = {
|
|||||||
};
|
};
|
||||||
introSkipper.d = function (msg) {
|
introSkipper.d = function (msg) {
|
||||||
console.debug("[intro skipper] ", msg);
|
console.debug("[intro skipper] ", msg);
|
||||||
}
|
}
|
||||||
/** Setup event listeners */
|
/** Setup event listeners */
|
||||||
introSkipper.setup = function () {
|
introSkipper.setup = function () {
|
||||||
document.addEventListener("viewshow", introSkipper.viewShow);
|
document.addEventListener("viewshow", introSkipper.viewShow);
|
||||||
window.fetch = introSkipper.fetchWrapper;
|
window.fetch = introSkipper.fetchWrapper;
|
||||||
introSkipper.d("Registered hooks");
|
introSkipper.d("Registered hooks");
|
||||||
}
|
}
|
||||||
/** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
|
/** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
|
||||||
introSkipper.fetchWrapper = async function (...args) {
|
introSkipper.fetchWrapper = async function (...args) {
|
||||||
// Based on JellyScrub's trickplay.js
|
// Based on JellyScrub's trickplay.js
|
||||||
let [resource, options] = args;
|
let [resource, options] = args;
|
||||||
let response = await introSkipper.originalFetch(resource, options);
|
let response = await introSkipper.originalFetch(resource, options);
|
||||||
// Bail early if this isn't a playback info URL
|
// Bail early if this isn't a playback info URL
|
||||||
try {
|
try {
|
||||||
let path = new URL(resource).pathname;
|
let path = new URL(resource).pathname;
|
||||||
if (!path.includes("/PlaybackInfo")) { return response; }
|
if (!path.includes("/PlaybackInfo")) { return response; }
|
||||||
introSkipper.d("Retrieving skip segments from URL");
|
introSkipper.d("Retrieving skip segments from URL");
|
||||||
introSkipper.d(path);
|
introSkipper.d(path);
|
||||||
let id = path.split("/")[2];
|
|
||||||
introSkipper.skipSegments = await introSkipper.secureFetch(`Episode/${id}/IntroSkipperSegments`);
|
// Check for context root and set id accordingly
|
||||||
introSkipper.d("Successfully retrieved skip segments");
|
let path_arr = path.split("/");
|
||||||
introSkipper.d(introSkipper.skipSegments);
|
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) {
|
catch (e) {
|
||||||
console.error("Unable to get skip segments from", resource, e);
|
console.error("Unable to get skip segments from", resource, e);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Event handler that runs whenever the current view changes.
|
* Event handler that runs whenever the current view changes.
|
||||||
* Used to detect the start of video playback.
|
* Used to detect the start of video playback.
|
||||||
*/
|
*/
|
||||||
introSkipper.viewShow = function () {
|
introSkipper.viewShow = function () {
|
||||||
const location = window.location.hash;
|
const location = window.location.hash;
|
||||||
introSkipper.d("Location changed to " + location);
|
introSkipper.d("Location changed to " + location);
|
||||||
if (location !== "#!/video") {
|
if (location !== "#!/video") {
|
||||||
introSkipper.d("Ignoring location change");
|
introSkipper.d("Ignoring location change");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
introSkipper.injectCss();
|
introSkipper.injectCss();
|
||||||
introSkipper.injectButton();
|
introSkipper.injectButton();
|
||||||
introSkipper.videoPlayer = document.querySelector("video");
|
introSkipper.videoPlayer = document.querySelector("video");
|
||||||
if (introSkipper.videoPlayer != null) {
|
if (introSkipper.videoPlayer != null) {
|
||||||
introSkipper.d("Hooking video timeupdate");
|
introSkipper.d("Hooking video timeupdate");
|
||||||
introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged);
|
introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Injects the CSS used by the skip intro button.
|
* Injects the CSS used by the skip intro button.
|
||||||
* Calling this function is a no-op if the CSS has already been injected.
|
* Calling this function is a no-op if the CSS has already been injected.
|
||||||
*/
|
*/
|
||||||
introSkipper.injectCss = function () {
|
introSkipper.injectCss = function () {
|
||||||
if (introSkipper.testElement("style#introSkipperCss")) {
|
if (introSkipper.testElement("style#introSkipperCss")) {
|
||||||
introSkipper.d("CSS already added");
|
introSkipper.d("CSS already added");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
introSkipper.d("Adding CSS");
|
introSkipper.d("Adding CSS");
|
||||||
let styleElement = document.createElement("style");
|
let styleElement = document.createElement("style");
|
||||||
styleElement.id = "introSkipperCss";
|
styleElement.id = "introSkipperCss";
|
||||||
styleElement.innerText = `
|
styleElement.innerText = `
|
||||||
|
:root {
|
||||||
|
--rounding: .2em;
|
||||||
|
--accent: 0, 164, 220;
|
||||||
|
}
|
||||||
|
#skipIntro.upNextContainer {
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
#skipIntro {
|
#skipIntro {
|
||||||
padding: 0 1px;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10em;
|
bottom: 6em;
|
||||||
bottom: 9em;
|
right: 4.5em;
|
||||||
background-color: rgba(25, 25, 25, 0.66);
|
background-color: transparent;
|
||||||
border: 1px solid;
|
font-size: 1.2em;
|
||||||
border-radius: 0px;
|
}
|
||||||
display: inline-block;
|
#skipIntro .emby-button {
|
||||||
cursor: pointer;
|
text-shadow: 0 0 3px rgba(0, 0, 0, 0.7);
|
||||||
box-shadow: inset 0 0 0 0 #f9f9f9;
|
border-radius: var(--rounding);
|
||||||
-webkit-transition: ease-out 0.4s;
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
-moz-transition: ease-out 0.4s;
|
will-change: opacity, transform;
|
||||||
transition: ease-out 0.4s;
|
opacity: 0;
|
||||||
@media (max-width: 1080px) {
|
transition: opacity 0.3s ease-in, transform 0.3s ease-out;
|
||||||
right: 10%;
|
}
|
||||||
}
|
#skipIntro .emby-button:hover,
|
||||||
&:hover {
|
#skipIntro .emby-button:focus {
|
||||||
box-shadow: inset 400px 0 0 0 #f9f9f9;
|
background-color: rgba(var(--accent),0.7);
|
||||||
-webkit-transition: ease-in 1s;
|
transform: scale(1.05);
|
||||||
-moz-transition: ease-in 1s;
|
}
|
||||||
transition: ease-in 1s;
|
#btnSkipSegmentText {
|
||||||
}
|
padding-right: 0.15em;
|
||||||
&.upNextContainer {
|
padding-left: 0.2em;
|
||||||
width: unset;
|
margin-top: -0.1em;
|
||||||
}
|
|
||||||
@media (hover:hover) and (pointer:fine) {
|
|
||||||
.paper-icon-button-light:hover:not(:disabled) {
|
|
||||||
color: black !important;
|
|
||||||
background-color: rgba(47, 93, 98, 0) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.paper-icon-button-light.show-focus:focus {
|
|
||||||
transform: scale(1.04) !important;
|
|
||||||
}
|
|
||||||
#btnSkipSegmentText {
|
|
||||||
padding-right: 3px;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
document.querySelector("head").appendChild(styleElement);
|
document.querySelector("head").appendChild(styleElement);
|
||||||
@ -135,7 +136,7 @@ introSkipper.injectButton = async function () {
|
|||||||
button.classList.add("hide");
|
button.classList.add("hide");
|
||||||
button.addEventListener("click", introSkipper.doSkip);
|
button.addEventListener("click", introSkipper.doSkip);
|
||||||
button.innerHTML = `
|
button.innerHTML = `
|
||||||
<button is="paper-icon-button-light" class="btnSkipIntro paper-icon-button-light injected">
|
<button is="emby-button" type="button" class="btnSkipIntro injected">
|
||||||
<span id="btnSkipSegmentText"></span>
|
<span id="btnSkipSegmentText"></span>
|
||||||
<span class="material-icons skip_next"></span>
|
<span class="material-icons skip_next"></span>
|
||||||
</button>
|
</button>
|
||||||
@ -174,21 +175,31 @@ introSkipper.videoPositionChanged = function () {
|
|||||||
if (!skipButton) {
|
if (!skipButton) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const embyButton = skipButton.querySelector(".emby-button");
|
||||||
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
|
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
|
||||||
switch (segment["SegmentType"]) {
|
switch (segment.SegmentType) {
|
||||||
case "None":
|
case "None":
|
||||||
skipButton.classList.add("hide");
|
if (embyButton.style.opacity === '0') return;
|
||||||
|
|
||||||
|
embyButton.style.opacity = '0';
|
||||||
|
embyButton.addEventListener("transitionend", () => {
|
||||||
|
skipButton.classList.add("hide");
|
||||||
|
}, { once: true });
|
||||||
return;
|
return;
|
||||||
case "Introduction":
|
case "Introduction":
|
||||||
skipButton.querySelector("#btnSkipSegmentText").textContent =
|
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.intro_text;
|
||||||
skipButton.dataset["intro_text"];
|
|
||||||
break;
|
break;
|
||||||
case "Credits":
|
case "Credits":
|
||||||
skipButton.querySelector("#btnSkipSegmentText").textContent =
|
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.credits_text;
|
||||||
skipButton.dataset["credits_text"];
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (!skipButton.classList.contains("hide")) return;
|
||||||
|
|
||||||
skipButton.classList.remove("hide");
|
skipButton.classList.remove("hide");
|
||||||
|
embyButton.offsetWidth; // Force reflow
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
embyButton.style.opacity = '1';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
/** Seeks to the end of the intro. */
|
/** Seeks to the end of the intro. */
|
||||||
introSkipper.doSkip = function (e) {
|
introSkipper.doSkip = function (e) {
|
||||||
|
@ -17,7 +17,7 @@ function findIntros() {
|
|||||||
// get the times of all similar fingerprint points
|
// get the times of all similar fingerprint points
|
||||||
for (let i in fprDiffs) {
|
for (let i in fprDiffs) {
|
||||||
if (fprDiffs[i] > fprDiffMinimum) {
|
if (fprDiffs[i] > fprDiffMinimum) {
|
||||||
times.push(i * 0.128);
|
times.push(i * 0.1238);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,7 +57,7 @@ function findIntros() {
|
|||||||
introsLog.style.left = "115px";
|
introsLog.style.left = "115px";
|
||||||
introsLog.innerHTML = "";
|
introsLog.innerHTML = "";
|
||||||
|
|
||||||
const offset = Number(txtOffset.value) * 0.128;
|
const offset = Number(txtOffset.value) * 0.1238;
|
||||||
for (let r of ranges) {
|
for (let r of ranges) {
|
||||||
let lStart, lEnd, rStart, rEnd;
|
let lStart, lEnd, rStart, rEnd;
|
||||||
|
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
<RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace>
|
<RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace>
|
||||||
<AssemblyVersion>0.1.16.3</AssemblyVersion>
|
<AssemblyVersion>0.10.8.1</AssemblyVersion>
|
||||||
<FileVersion>0.1.16.3</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>
|
||||||
@ -24,4 +24,4 @@
|
|||||||
<EmbeddedResource Include="Configuration\inject.js" />
|
<EmbeddedResource Include="Configuration\inject.js" />
|
||||||
<EmbeddedResource Include="Configuration\version.txt" />
|
<EmbeddedResource Include="Configuration\version.txt" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Net.Mime;
|
using System.Net.Mime;
|
||||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||||
|
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||||
using MediaBrowser.Controller.Entities.TV;
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -47,6 +51,74 @@ public class SkipIntroController : ControllerBase
|
|||||||
return intro;
|
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>
|
/// <summary>
|
||||||
/// Gets a dictionary of all skippable segments.
|
/// Gets a dictionary of all skippable segments.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -86,19 +158,19 @@ public class SkipIntroController : ControllerBase
|
|||||||
// Operate on a copy to avoid mutating the original Intro object stored in the dictionary.
|
// Operate on a copy to avoid mutating the original Intro object stored in the dictionary.
|
||||||
var segment = new Intro(timestamp);
|
var segment = new Intro(timestamp);
|
||||||
|
|
||||||
var config = Plugin.Instance!.Configuration;
|
var config = Plugin.Instance.Configuration;
|
||||||
segment.IntroEnd -= config.SecondsOfIntroToPlay;
|
segment.IntroEnd -= config.RemainingSecondsOfIntro;
|
||||||
if (config.PersistSkipButton)
|
if (config.PersistSkipButton)
|
||||||
{
|
{
|
||||||
segment.ShowSkipPromptAt = segment.IntroStart;
|
segment.ShowSkipPromptAt = segment.IntroStart;
|
||||||
segment.HideSkipPromptAt = segment.IntroEnd;
|
segment.HideSkipPromptAt = segment.IntroEnd - 1;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
|
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
|
||||||
segment.HideSkipPromptAt = Math.Min(
|
segment.HideSkipPromptAt = Math.Min(
|
||||||
segment.IntroStart + config.HidePromptAdjustment,
|
segment.IntroStart + config.HidePromptAdjustment,
|
||||||
segment.IntroEnd);
|
segment.IntroEnd - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return segment;
|
return segment;
|
||||||
@ -153,8 +225,8 @@ public class SkipIntroController : ControllerBase
|
|||||||
foreach (var intro in timestamps)
|
foreach (var intro in timestamps)
|
||||||
{
|
{
|
||||||
// Get the details of the item from Jellyfin
|
// Get the details of the item from Jellyfin
|
||||||
var rawItem = Plugin.Instance!.GetItem(intro.Key);
|
var rawItem = Plugin.Instance.GetItem(intro.Key);
|
||||||
if (rawItem is not Episode episode)
|
if (rawItem == null || rawItem is not Episode episode)
|
||||||
{
|
{
|
||||||
throw new InvalidCastException("Unable to cast item id " + intro.Key + " to an Episode");
|
throw new InvalidCastException("Unable to cast item id " + intro.Key + " to an Episode");
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Net.Mime;
|
using System.Net.Mime;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Net.Mime;
|
using System.Net.Mime;
|
||||||
|
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -140,6 +144,7 @@ public class VisualizationController : ControllerBase
|
|||||||
foreach (var e in episodes)
|
foreach (var e in episodes)
|
||||||
{
|
{
|
||||||
Plugin.Instance!.Intros.Remove(e.EpisodeId);
|
Plugin.Instance!.Intros.Remove(e.EpisodeId);
|
||||||
|
Plugin.Instance!.Credits.Remove(e.EpisodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Plugin.Instance!.SaveTimestamps();
|
Plugin.Instance!.SaveTimestamps();
|
||||||
@ -148,18 +153,22 @@ public class VisualizationController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates the timestamps for the provided episode.
|
/// Updates the introduction timestamps for the provided episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">Episode ID to update timestamps for.</param>
|
/// <param name="id">Episode ID to update timestamps for.</param>
|
||||||
/// <param name="timestamps">New introduction start and end times.</param>
|
/// <param name="timestamps">New introduction start and end times.</param>
|
||||||
/// <response code="204">New introduction timestamps saved.</response>
|
/// <response code="204">New introduction timestamps saved.</response>
|
||||||
/// <returns>No content.</returns>
|
/// <returns>No content.</returns>
|
||||||
[HttpPost("Episode/{Id}/UpdateIntroTimestamps")]
|
[HttpPost("Episode/{Id}/UpdateIntroTimestamps")]
|
||||||
public ActionResult UpdateTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps)
|
[Obsolete("deprecated use Episode/{Id}/Timestamps")]
|
||||||
|
public ActionResult UpdateIntroTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps)
|
||||||
{
|
{
|
||||||
var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd);
|
if (timestamps.IntroEnd > 0.0)
|
||||||
Plugin.Instance!.Intros[id] = new Intro(id, tr);
|
{
|
||||||
Plugin.Instance!.SaveTimestamps();
|
var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd);
|
||||||
|
Plugin.Instance!.Intros[id] = new Intro(id, tr);
|
||||||
|
Plugin.Instance.SaveTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -34,4 +37,9 @@ public enum EdlAction
|
|||||||
/// Show a skip button.
|
/// Show a skip button.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Intro,
|
Intro,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Show a skip button.
|
||||||
|
/// </summary>
|
||||||
|
Credit,
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
22
ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeStamps.cs
Normal file
22
ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeStamps.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data
|
||||||
|
{
|
||||||
|
/// <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 TimeStamps
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Introduction.
|
||||||
|
/// </summary>
|
||||||
|
public Intro Introduction { get; set; } = new Intro();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Credits.
|
||||||
|
/// </summary>
|
||||||
|
public Intro Credits { get; set; } = new Intro();
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
@ -63,14 +66,12 @@ public static class EdlManager
|
|||||||
{
|
{
|
||||||
var id = episode.EpisodeId;
|
var id = episode.EpisodeId;
|
||||||
|
|
||||||
if (!Plugin.Instance!.Intros.TryGetValue(id, out var intro))
|
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} did not have an introduction, skipping", id);
|
_logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
else if (!intro.Valid)
|
|
||||||
{
|
|
||||||
_logger?.LogDebug("Episode {Id} did not have a valid introduction, skipping", id);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,7 +85,31 @@ public static class EdlManager
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
File.WriteAllText(edlPath, intro.ToEdl(action));
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,15 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Plugins;
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
@ -14,9 +21,15 @@ public class Entrypoint : IServerEntryPoint
|
|||||||
{
|
{
|
||||||
private readonly IUserManager _userManager;
|
private readonly IUserManager _userManager;
|
||||||
private readonly IUserViewManager _userViewManager;
|
private readonly IUserViewManager _userViewManager;
|
||||||
|
private readonly ITaskManager _taskManager;
|
||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly ILogger<Entrypoint> _logger;
|
private readonly ILogger<Entrypoint> _logger;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
private Timer _queueTimer;
|
||||||
|
private bool _analyzeAgain;
|
||||||
|
private static CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private static ManualResetEventSlim _autoTaskCompletEvent = new ManualResetEventSlim(false);
|
||||||
|
private QueueManager _queueManager;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="Entrypoint"/> class.
|
/// Initializes a new instance of the <see cref="Entrypoint"/> class.
|
||||||
@ -24,20 +37,51 @@ public class Entrypoint : IServerEntryPoint
|
|||||||
/// <param name="userManager">User manager.</param>
|
/// <param name="userManager">User manager.</param>
|
||||||
/// <param name="userViewManager">User view manager.</param>
|
/// <param name="userViewManager">User view manager.</param>
|
||||||
/// <param name="libraryManager">Library manager.</param>
|
/// <param name="libraryManager">Library manager.</param>
|
||||||
|
/// <param name="taskManager">Task manager.</param>
|
||||||
/// <param name="logger">Logger.</param>
|
/// <param name="logger">Logger.</param>
|
||||||
/// <param name="loggerFactory">Logger factory.</param>
|
/// <param name="loggerFactory">Logger factory.</param>
|
||||||
public Entrypoint(
|
public Entrypoint(
|
||||||
IUserManager userManager,
|
IUserManager userManager,
|
||||||
IUserViewManager userViewManager,
|
IUserViewManager userViewManager,
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
|
ITaskManager taskManager,
|
||||||
ILogger<Entrypoint> logger,
|
ILogger<Entrypoint> logger,
|
||||||
ILoggerFactory loggerFactory)
|
ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_userViewManager = userViewManager;
|
_userViewManager = userViewManager;
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
|
_taskManager = taskManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_loggerFactory = loggerFactory;
|
_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>
|
/// <summary>
|
||||||
@ -46,17 +90,17 @@ public class Entrypoint : IServerEntryPoint
|
|||||||
/// <returns>Task.</returns>
|
/// <returns>Task.</returns>
|
||||||
public Task RunAsync()
|
public Task RunAsync()
|
||||||
{
|
{
|
||||||
FFmpegWrapper.Logger = _logger;
|
_libraryManager.ItemAdded += OnItemAdded;
|
||||||
|
_libraryManager.ItemUpdated += OnItemModified;
|
||||||
|
_taskManager.TaskCompleted += OnLibraryRefresh;
|
||||||
|
|
||||||
// TODO: when a new item is added to the server, immediately analyze the season it belongs to
|
FFmpegWrapper.Logger = _logger;
|
||||||
// instead of waiting for the next task interval. The task start should be debounced by a few seconds.
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible
|
// Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible
|
||||||
_logger.LogInformation("Running startup enqueue");
|
_logger.LogInformation("Running startup enqueue");
|
||||||
var queueManager = new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager);
|
_queueManager.GetMediaItems();
|
||||||
queueManager.GetMediaItems();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -66,6 +110,217 @@ public class Entrypoint : IServerEntryPoint
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disclose source for inspiration
|
||||||
|
// Implementation based on the principles of jellyfin-plugin-media-analyzer:
|
||||||
|
// https://github.com/endrl/jellyfin-plugin-media-analyzer
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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>
|
/// <summary>
|
||||||
/// Dispose.
|
/// Dispose.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -83,6 +338,19 @@ public class Entrypoint : IServerEntryPoint
|
|||||||
{
|
{
|
||||||
if (!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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
@ -14,8 +18,6 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class FFmpegWrapper
|
public static class FFmpegWrapper
|
||||||
{
|
{
|
||||||
private static readonly object InvertedIndexCacheLock = new();
|
|
||||||
|
|
||||||
/// <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>
|
||||||
@ -34,7 +36,7 @@ public static class FFmpegWrapper
|
|||||||
|
|
||||||
private static Dictionary<string, string> ChromaprintLogs { get; set; } = new();
|
private static Dictionary<string, string> ChromaprintLogs { get; set; } = new();
|
||||||
|
|
||||||
private static Dictionary<Guid, Dictionary<uint, int>> InvertedIndexCache { 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.
|
||||||
@ -137,15 +139,16 @@ public static class FFmpegWrapper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">Episode ID.</param>
|
/// <param name="id">Episode ID.</param>
|
||||||
/// <param name="fingerprint">Chromaprint fingerprint.</param>
|
/// <param name="fingerprint">Chromaprint fingerprint.</param>
|
||||||
|
/// <param name="mode">Mode.</param>
|
||||||
/// <returns>Inverted index.</returns>
|
/// <returns>Inverted index.</returns>
|
||||||
public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint)
|
public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint, AnalysisMode mode)
|
||||||
{
|
{
|
||||||
lock (InvertedIndexCacheLock)
|
var innerDictionary = InvertedIndexCache.GetOrAdd(mode, _ => new ConcurrentDictionary<Guid, Dictionary<uint, int>>());
|
||||||
|
|
||||||
|
// Check if cached for the ID
|
||||||
|
if (innerDictionary.TryGetValue(id, out var cached))
|
||||||
{
|
{
|
||||||
if (InvertedIndexCache.TryGetValue(id, out var cached))
|
return cached;
|
||||||
{
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var invIndex = new Dictionary<uint, int>();
|
var invIndex = new Dictionary<uint, int>();
|
||||||
@ -159,10 +162,7 @@ public static class FFmpegWrapper
|
|||||||
invIndex[point] = i;
|
invIndex[point] = i;
|
||||||
}
|
}
|
||||||
|
|
||||||
lock (InvertedIndexCacheLock)
|
innerDictionary[id] = invIndex;
|
||||||
{
|
|
||||||
InvertedIndexCache[id] = invIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
return invIndex;
|
return invIndex;
|
||||||
}
|
}
|
||||||
@ -261,24 +261,29 @@ public static class FFmpegWrapper
|
|||||||
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
|
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
|
||||||
foreach (var line in raw.Split('\n'))
|
foreach (var line in raw.Split('\n'))
|
||||||
{
|
{
|
||||||
var matches = BlackFrameRegex.Matches(line);
|
// There is no FFmpeg flag to hide metadata such as description
|
||||||
if (matches.Count != 2)
|
// In our case, the metadata contained something that matched the regex.
|
||||||
|
if (line.StartsWith("[Parsed_blackframe_", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
continue;
|
var matches = BlackFrameRegex.Matches(line);
|
||||||
}
|
if (matches.Count != 2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var (strPercent, strTime) = (
|
var (strPercent, strTime) = (
|
||||||
matches[0].Value.Split(':')[1],
|
matches[0].Value.Split(':')[1],
|
||||||
matches[1].Value.Split(':')[1]
|
matches[1].Value.Split(':')[1]
|
||||||
);
|
);
|
||||||
|
|
||||||
var bf = new BlackFrame(
|
var bf = new BlackFrame(
|
||||||
Convert.ToInt32(strPercent, CultureInfo.InvariantCulture),
|
Convert.ToInt32(strPercent, CultureInfo.InvariantCulture),
|
||||||
Convert.ToDouble(strTime, CultureInfo.InvariantCulture));
|
Convert.ToDouble(strTime, CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
if (bf.Percentage > minimum)
|
if (bf.Percentage > minimum)
|
||||||
{
|
{
|
||||||
blackFrames.Add(bf);
|
blackFrames.Add(bf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -415,52 +420,46 @@ public static class FFmpegWrapper
|
|||||||
RedirectStandardError = stderr
|
RedirectStandardError = stderr
|
||||||
};
|
};
|
||||||
|
|
||||||
var ffmpeg = new Process
|
using (var ffmpeg = new Process { StartInfo = info })
|
||||||
{
|
{
|
||||||
StartInfo = info
|
Logger?.LogDebug("Starting ffmpeg with the following arguments: {Arguments}", ffmpeg.StartInfo.Arguments);
|
||||||
};
|
|
||||||
|
|
||||||
Logger?.LogDebug(
|
ffmpeg.Start();
|
||||||
"Starting ffmpeg with the following arguments: {Arguments}",
|
|
||||||
ffmpeg.StartInfo.Arguments);
|
|
||||||
|
|
||||||
ffmpeg.Start();
|
try
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ffmpeg.PriorityClass = Plugin.Instance?.Configuration.ProcessPriority ?? ProcessPriorityClass.BelowNormal;
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Logger?.LogDebug(
|
|
||||||
"ffmpeg priority could not be modified. {Message}",
|
|
||||||
e.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
using (MemoryStream ms = new MemoryStream())
|
|
||||||
{
|
|
||||||
var buf = new byte[4096];
|
|
||||||
var bytesRead = 0;
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
{
|
||||||
var streamReader = stderr ? ffmpeg.StandardError : ffmpeg.StandardOutput;
|
ffmpeg.PriorityClass = Plugin.Instance?.Configuration.ProcessPriority ?? ProcessPriorityClass.BelowNormal;
|
||||||
bytesRead = streamReader.BaseStream.Read(buf, 0, buf.Length);
|
|
||||||
ms.Write(buf, 0, bytesRead);
|
|
||||||
}
|
}
|
||||||
while (bytesRead > 0);
|
catch (Exception e)
|
||||||
|
|
||||||
ffmpeg.WaitForExit(timeout);
|
|
||||||
|
|
||||||
var output = ms.ToArray();
|
|
||||||
|
|
||||||
// If caching is enabled, cache the output of this command.
|
|
||||||
if (cacheOutput)
|
|
||||||
{
|
{
|
||||||
File.WriteAllBytes(cacheFilename, output);
|
Logger?.LogDebug("ffmpeg priority could not be modified. {Message}", e.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
using (var ms = new MemoryStream())
|
||||||
|
{
|
||||||
|
var buf = new byte[4096];
|
||||||
|
int bytesRead;
|
||||||
|
|
||||||
|
using (var streamReader = stderr ? ffmpeg.StandardError : ffmpeg.StandardOutput)
|
||||||
|
{
|
||||||
|
while ((bytesRead = streamReader.BaseStream.Read(buf, 0, buf.Length)) > 0)
|
||||||
|
{
|
||||||
|
ms.Write(buf, 0, bytesRead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg.WaitForExit(timeout);
|
||||||
|
|
||||||
|
var output = ms.ToArray();
|
||||||
|
|
||||||
|
// If caching is enabled, cache the output of this command.
|
||||||
|
if (cacheOutput)
|
||||||
|
{
|
||||||
|
File.WriteAllBytes(cacheFilename, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
// This file is used by Code Analysis to maintain SuppressMessage
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
// attributes that are applied to this project.
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
// Project-level suppressions either have no target or are given
|
|
||||||
// a specific target and scoped to a namespace, type, member, etc.
|
|
||||||
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
@ -1,18 +1,22 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Plugins;
|
using MediaBrowser.Common.Plugins;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Entities.TV;
|
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Persistence;
|
using MediaBrowser.Controller.Persistence;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Model.Plugins;
|
using MediaBrowser.Model.Plugins;
|
||||||
using MediaBrowser.Model.Serialization;
|
using MediaBrowser.Model.Serialization;
|
||||||
|
using MediaBrowser.Model.Updates;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
@ -61,15 +65,27 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
|
|
||||||
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
|
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
|
||||||
|
|
||||||
var introsDirectory = Path.Join(applicationPaths.CachePath, "introskipper");
|
var pluginDirName = "introskipper";
|
||||||
FingerprintCachePath = Path.Join(introsDirectory, "chromaprints");
|
var pluginCachePath = "chromaprints";
|
||||||
_introPath = Path.Join(applicationPaths.CachePath, "introskipper", "intros.xml");
|
|
||||||
_creditsPath = Path.Join(applicationPaths.CachePath, "introskipper", "credits.xml");
|
|
||||||
|
|
||||||
var oldintrosDirectory = Path.Join(applicationPaths.PluginConfigurationsPath, "intros");
|
var introsDirectory = Path.Join(applicationPaths.DataPath, pluginDirName);
|
||||||
_oldFingerprintCachePath = Path.Join(oldintrosDirectory, "cache");
|
FingerprintCachePath = Path.Join(introsDirectory, pluginCachePath);
|
||||||
_oldintroPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml");
|
_introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml");
|
||||||
_oldcreditsPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "credits.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).
|
// Create the base & cache directories (if needed).
|
||||||
if (!Directory.Exists(FingerprintCachePath))
|
if (!Directory.Exists(FingerprintCachePath))
|
||||||
@ -79,9 +95,19 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
// Check if the old cache directory exists
|
// Check if the old cache directory exists
|
||||||
if (Directory.Exists(_oldFingerprintCachePath))
|
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
|
// Move the contents from old directory to new directory
|
||||||
File.Move(_oldintroPath, _introPath);
|
|
||||||
File.Move(_oldcreditsPath, _creditsPath);
|
|
||||||
string[] files = Directory.GetFiles(_oldFingerprintCachePath);
|
string[] files = Directory.GetFiles(_oldFingerprintCachePath);
|
||||||
foreach (string file in files)
|
foreach (string file in files)
|
||||||
{
|
{
|
||||||
@ -97,6 +123,8 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
|
|
||||||
ConfigurationChanged += OnConfigurationChanged;
|
ConfigurationChanged += OnConfigurationChanged;
|
||||||
|
|
||||||
|
MigrateRepoUrl(serverConfiguration);
|
||||||
|
|
||||||
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
|
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -144,6 +172,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event EventHandler? AutoSkipChanged;
|
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>
|
/// <summary>
|
||||||
/// Gets the results of fingerprinting all episodes.
|
/// Gets the results of fingerprinting all episodes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -200,7 +233,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
var introList = new List<Intro>();
|
var introList = new List<Intro>();
|
||||||
|
|
||||||
// Serialize intros
|
// Serialize intros
|
||||||
foreach (var intro in Plugin.Instance!.Intros)
|
foreach (var intro in Instance!.Intros)
|
||||||
{
|
{
|
||||||
introList.Add(intro.Value);
|
introList.Add(intro.Value);
|
||||||
}
|
}
|
||||||
@ -210,7 +243,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
// Serialize credits
|
// Serialize credits
|
||||||
introList.Clear();
|
introList.Clear();
|
||||||
|
|
||||||
foreach (var intro in Plugin.Instance!.Credits)
|
foreach (var intro in Instance!.Credits)
|
||||||
{
|
{
|
||||||
introList.Add(intro.Value);
|
introList.Add(intro.Value);
|
||||||
}
|
}
|
||||||
@ -233,7 +266,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
|
|
||||||
foreach (var intro in introList)
|
foreach (var intro in introList)
|
||||||
{
|
{
|
||||||
Plugin.Instance!.Intros[intro.EpisodeId] = intro;
|
Instance!.Intros[intro.EpisodeId] = intro;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,7 +278,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
|
|
||||||
foreach (var credit in creditList)
|
foreach (var credit in creditList)
|
||||||
{
|
{
|
||||||
Plugin.Instance!.Credits[credit.EpisodeId] = credit;
|
Instance!.Credits[credit.EpisodeId] = credit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -302,7 +335,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
return commit;
|
return commit;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal BaseItem GetItem(Guid id)
|
internal BaseItem? GetItem(Guid id)
|
||||||
{
|
{
|
||||||
return _libraryManager.GetItemById(id);
|
return _libraryManager.GetItemById(id);
|
||||||
}
|
}
|
||||||
@ -314,7 +347,15 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
/// <returns>Full path to item.</returns>
|
/// <returns>Full path to item.</returns>
|
||||||
internal string GetItemPath(Guid id)
|
internal string GetItemPath(Guid id)
|
||||||
{
|
{
|
||||||
return GetItem(id).Path;
|
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>
|
/// <summary>
|
||||||
@ -324,7 +365,15 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
/// <returns>List of chapters.</returns>
|
/// <returns>List of chapters.</returns>
|
||||||
internal List<ChapterInfo> GetChapters(Guid id)
|
internal List<ChapterInfo> GetChapters(Guid id)
|
||||||
{
|
{
|
||||||
return _itemRepository.GetChapters(GetItem(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)
|
internal void UpdateTimestamps(Dictionary<Guid, Intro> newTimestamps, AnalysisMode mode)
|
||||||
@ -335,21 +384,69 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
{
|
{
|
||||||
if (mode == AnalysisMode.Introduction)
|
if (mode == AnalysisMode.Introduction)
|
||||||
{
|
{
|
||||||
Plugin.Instance!.Intros[intro.Key] = intro.Value;
|
Instance!.Intros[intro.Key] = intro.Value;
|
||||||
}
|
}
|
||||||
else if (mode == AnalysisMode.Credits)
|
else if (mode == AnalysisMode.Credits)
|
||||||
{
|
{
|
||||||
Plugin.Instance!.Credits[intro.Key] = intro.Value;
|
Instance!.Credits[intro.Key] = intro.Value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Plugin.Instance!.SaveTimestamps();
|
Instance!.SaveTimestamps();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnConfigurationChanged(object? sender, BasePluginConfiguration e)
|
private void OnConfigurationChanged(object? sender, BasePluginConfiguration e)
|
||||||
{
|
{
|
||||||
AutoSkipChanged?.Invoke(this, EventArgs.Empty);
|
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>
|
/// <summary>
|
||||||
@ -363,7 +460,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
|
|
||||||
_logger.LogDebug("Reading index.html from {Path}", indexPath);
|
_logger.LogDebug("Reading index.html from {Path}", indexPath);
|
||||||
var contents = File.ReadAllText(indexPath);
|
var contents = File.ReadAllText(indexPath);
|
||||||
_logger.LogDebug("Successfully read index.html");
|
|
||||||
|
|
||||||
var scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js\"></script>";
|
var scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js\"></script>";
|
||||||
|
|
||||||
@ -376,12 +472,10 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
|
|
||||||
// Inject a link to the script at the end of the <head> section.
|
// 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.
|
// A regex is used here to ensure the replacement is only done once.
|
||||||
_logger.LogDebug("Injecting script tag");
|
|
||||||
var headEnd = new Regex("</head>", RegexOptions.IgnoreCase);
|
var headEnd = new Regex("</head>", RegexOptions.IgnoreCase);
|
||||||
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
|
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
|
||||||
|
|
||||||
// Write the modified file contents
|
// Write the modified file contents
|
||||||
_logger.LogDebug("Saving modified file");
|
|
||||||
File.WriteAllText(indexPath, contents);
|
File.WriteAllText(indexPath, contents);
|
||||||
|
|
||||||
_logger.LogInformation("Skip intro button successfully added");
|
_logger.LogInformation("Skip intro button successfully added");
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
@ -57,15 +60,21 @@ public class QueueManager
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation("Running enqueue of items in library {Name}", folder.Name);
|
||||||
"Running enqueue of items in library {Name}",
|
|
||||||
folder.Name);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var location in folder.Locations)
|
foreach (var location in folder.Locations)
|
||||||
{
|
{
|
||||||
QueueLibraryContents(_libraryManager.FindByPath(location, true).Id);
|
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)
|
catch (Exception ex)
|
||||||
@ -114,7 +123,7 @@ public class QueueManager
|
|||||||
if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15)
|
if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Analysis settings have been changed to: {Percent}%/{Minutes}m and a minimum of {Minimum}s",
|
"Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s",
|
||||||
config.AnalysisPercent,
|
config.AnalysisPercent,
|
||||||
config.AnalysisLengthLimit,
|
config.AnalysisLengthLimit,
|
||||||
config.MinimumIntroDuration);
|
config.MinimumIntroDuration);
|
||||||
@ -140,8 +149,6 @@ public class QueueManager
|
|||||||
IsVirtualItem = false
|
IsVirtualItem = false
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogDebug("Getting items");
|
|
||||||
|
|
||||||
var items = _libraryManager.GetItemList(query, false);
|
var items = _libraryManager.GetItemList(query, false);
|
||||||
|
|
||||||
if (items is null)
|
if (items is null)
|
||||||
@ -161,13 +168,25 @@ public class QueueManager
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Plugin.Instance!.Configuration.PathRestrictions.Count > 0)
|
||||||
|
{
|
||||||
|
if (!Plugin.Instance!.Configuration.PathRestrictions.Contains(item.ContainingFolderPath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
QueueEpisode(episode);
|
QueueEpisode(episode);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("Queued {Count} episodes", items.Count);
|
_logger.LogDebug("Queued {Count} episodes", items.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void QueueEpisode(Episode episode)
|
/// <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)
|
if (Plugin.Instance is null)
|
||||||
{
|
{
|
||||||
@ -184,6 +203,19 @@ public class QueueManager
|
|||||||
return;
|
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.
|
// Limit analysis to the first X% of the episode and at most Y minutes.
|
||||||
// X and Y default to 25% and 10 minutes.
|
// X and Y default to 25% and 10 minutes.
|
||||||
var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
|
var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
|
||||||
@ -198,11 +230,8 @@ public class QueueManager
|
|||||||
fingerprintDuration,
|
fingerprintDuration,
|
||||||
60 * Plugin.Instance!.Configuration.AnalysisLengthLimit);
|
60 * Plugin.Instance!.Configuration.AnalysisLengthLimit);
|
||||||
|
|
||||||
// Allocate a new list for each new season
|
|
||||||
_queuedEpisodes.TryAdd(episode.SeasonId, new List<QueuedEpisode>());
|
|
||||||
|
|
||||||
// Queue the episode for analysis
|
// Queue the episode for analysis
|
||||||
var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumEpisodeCreditsDuration;
|
var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumCreditsDuration;
|
||||||
_queuedEpisodes[episode.SeasonId].Add(new QueuedEpisode()
|
_queuedEpisodes[episode.SeasonId].Add(new QueuedEpisode()
|
||||||
{
|
{
|
||||||
SeriesName = episode.SeriesName,
|
SeriesName = episode.SeriesName,
|
||||||
@ -223,17 +252,16 @@ public class QueueManager
|
|||||||
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="candidates">Queued media items.</param>
|
/// <param name="candidates">Queued media items.</param>
|
||||||
/// <param name="mode">Analysis mode.</param>
|
/// <param name="modes">Analysis mode.</param>
|
||||||
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
|
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
|
||||||
public (ReadOnlyCollection<QueuedEpisode> VerifiedItems, bool AnyUnanalyzed)
|
public (ReadOnlyCollection<QueuedEpisode> VerifiedItems, ReadOnlyCollection<AnalysisMode> RequiredModes)
|
||||||
VerifyQueue(ReadOnlyCollection<QueuedEpisode> candidates, AnalysisMode mode)
|
VerifyQueue(ReadOnlyCollection<QueuedEpisode> candidates, ReadOnlyCollection<AnalysisMode> modes)
|
||||||
{
|
{
|
||||||
var unanalyzed = false;
|
|
||||||
var verified = new List<QueuedEpisode>();
|
var verified = new List<QueuedEpisode>();
|
||||||
|
var reqModes = new List<AnalysisMode>();
|
||||||
|
|
||||||
var timestamps = mode == AnalysisMode.Introduction ?
|
var requiresIntroAnalysis = modes.Contains(AnalysisMode.Introduction);
|
||||||
Plugin.Instance!.Intros :
|
var requiresCreditsAnalysis = modes.Contains(AnalysisMode.Credits);
|
||||||
Plugin.Instance!.Credits;
|
|
||||||
|
|
||||||
foreach (var candidate in candidates)
|
foreach (var candidate in candidates)
|
||||||
{
|
{
|
||||||
@ -244,24 +272,31 @@ public class QueueManager
|
|||||||
if (File.Exists(path))
|
if (File.Exists(path))
|
||||||
{
|
{
|
||||||
verified.Add(candidate);
|
verified.Add(candidate);
|
||||||
}
|
|
||||||
|
|
||||||
if (!timestamps.ContainsKey(candidate.EpisodeId))
|
if (requiresIntroAnalysis && (!Plugin.Instance!.Intros.TryGetValue(candidate.EpisodeId, out var intro) || !intro.Valid))
|
||||||
{
|
{
|
||||||
unanalyzed = true;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Skipping {Mode} analysis of {Name} ({Id}): {Exception}",
|
"Skipping {Mode} analysis of {Name} ({Id}): {Exception}",
|
||||||
mode,
|
modes,
|
||||||
candidate.Name,
|
candidate.Name,
|
||||||
candidate.EpisodeId,
|
candidate.EpisodeId,
|
||||||
ex);
|
ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (verified.AsReadOnly(), unanalyzed);
|
return (verified.AsReadOnly(), reqModes.AsReadOnly());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@ -12,7 +16,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class BaseItemAnalyzerTask
|
public class BaseItemAnalyzerTask
|
||||||
{
|
{
|
||||||
private readonly AnalysisMode _analysisMode;
|
private readonly ReadOnlyCollection<AnalysisMode> _analysisModes;
|
||||||
|
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
@ -23,22 +27,22 @@ public class BaseItemAnalyzerTask
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="BaseItemAnalyzerTask"/> class.
|
/// Initializes a new instance of the <see cref="BaseItemAnalyzerTask"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="mode">Analysis mode.</param>
|
/// <param name="modes">Analysis mode.</param>
|
||||||
/// <param name="logger">Task logger.</param>
|
/// <param name="logger">Task logger.</param>
|
||||||
/// <param name="loggerFactory">Logger factory.</param>
|
/// <param name="loggerFactory">Logger factory.</param>
|
||||||
/// <param name="libraryManager">Library manager.</param>
|
/// <param name="libraryManager">Library manager.</param>
|
||||||
public BaseItemAnalyzerTask(
|
public BaseItemAnalyzerTask(
|
||||||
AnalysisMode mode,
|
ReadOnlyCollection<AnalysisMode> modes,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
ILibraryManager libraryManager)
|
ILibraryManager libraryManager)
|
||||||
{
|
{
|
||||||
_analysisMode = mode;
|
_analysisModes = modes;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
|
|
||||||
if (mode == AnalysisMode.Introduction)
|
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
|
||||||
{
|
{
|
||||||
EdlManager.Initialize(_logger);
|
EdlManager.Initialize(_logger);
|
||||||
}
|
}
|
||||||
@ -73,18 +77,21 @@ public class BaseItemAnalyzerTask
|
|||||||
totalQueued += kvp.Value.Count;
|
totalQueued += kvp.Value.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalQueued *= _analysisModes.Count;
|
||||||
|
|
||||||
if (totalQueued == 0)
|
if (totalQueued == 0)
|
||||||
{
|
{
|
||||||
throw new FingerprintException(
|
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.");
|
"No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._analysisMode == AnalysisMode.Introduction)
|
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
|
||||||
{
|
{
|
||||||
EdlManager.LogConfiguration();
|
EdlManager.LogConfiguration();
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalProcessed = 0;
|
var totalProcessed = 0;
|
||||||
|
var modeCount = _analysisModes.Count;
|
||||||
var options = new ParallelOptions()
|
var options = new ParallelOptions()
|
||||||
{
|
{
|
||||||
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
|
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
|
||||||
@ -94,29 +101,55 @@ public class BaseItemAnalyzerTask
|
|||||||
{
|
{
|
||||||
var writeEdl = false;
|
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
|
// 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.
|
// of the current media items were deleted from Jellyfin since the task was started.
|
||||||
var (episodes, unanalyzed) = queueManager.VerifyQueue(
|
var (episodes, requiredModes) = queueManager.VerifyQueue(
|
||||||
season.Value.AsReadOnly(),
|
season.Value.AsReadOnly(),
|
||||||
this._analysisMode);
|
_analysisModes);
|
||||||
|
|
||||||
if (episodes.Count == 0)
|
var episodeCount = episodes.Count;
|
||||||
|
|
||||||
|
if (episodeCount == 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var first = episodes[0];
|
var first = episodes[0];
|
||||||
|
var requiredModeCount = requiredModes.Count;
|
||||||
|
|
||||||
if (!unanalyzed)
|
if (requiredModeCount == 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"All episodes in {Name} season {Season} have already been analyzed",
|
"All episodes in {Name} season {Season} have already been analyzed",
|
||||||
first.SeriesName,
|
first.SeriesName,
|
||||||
first.SeasonNumber);
|
first.SeasonNumber);
|
||||||
|
|
||||||
|
Interlocked.Add(ref totalProcessed, episodeCount * modeCount); // Update total Processed directly
|
||||||
|
progress.Report((totalProcessed * 100) / totalQueued);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (modeCount != requiredModeCount)
|
||||||
|
{
|
||||||
|
Interlocked.Add(ref totalProcessed, episodeCount);
|
||||||
|
progress.Report((totalProcessed * 100) / totalQueued); // Partial analysis some modes have already been analyzed
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested)
|
if (cancellationToken.IsCancellationRequested)
|
||||||
@ -124,10 +157,15 @@ public class BaseItemAnalyzerTask
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var analyzed = AnalyzeItems(episodes, cancellationToken);
|
foreach (AnalysisMode mode in requiredModes)
|
||||||
Interlocked.Add(ref totalProcessed, analyzed);
|
{
|
||||||
|
var analyzed = AnalyzeItems(episodes, mode, cancellationToken);
|
||||||
|
Interlocked.Add(ref totalProcessed, analyzed);
|
||||||
|
|
||||||
writeEdl = analyzed > 0 || Plugin.Instance!.Configuration.RegenerateEdlFiles;
|
writeEdl = analyzed > 0 || Plugin.Instance!.Configuration.RegenerateEdlFiles;
|
||||||
|
|
||||||
|
progress.Report((totalProcessed * 100) / totalQueued);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (FingerprintException ex)
|
catch (FingerprintException ex)
|
||||||
{
|
{
|
||||||
@ -138,20 +176,13 @@ public class BaseItemAnalyzerTask
|
|||||||
ex);
|
ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (writeEdl && Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
|
||||||
writeEdl &&
|
|
||||||
Plugin.Instance!.Configuration.EdlAction != EdlAction.None &&
|
|
||||||
_analysisMode == AnalysisMode.Introduction)
|
|
||||||
{
|
{
|
||||||
EdlManager.UpdateEDLFiles(episodes);
|
EdlManager.UpdateEDLFiles(episodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.Report((totalProcessed * 100) / totalQueued);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (Plugin.Instance!.Configuration.RegenerateEdlFiles)
|
||||||
_analysisMode == AnalysisMode.Introduction &&
|
|
||||||
Plugin.Instance!.Configuration.RegenerateEdlFiles)
|
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Turning EDL file regeneration flag off");
|
_logger.LogInformation("Turning EDL file regeneration flag off");
|
||||||
Plugin.Instance!.Configuration.RegenerateEdlFiles = false;
|
Plugin.Instance!.Configuration.RegenerateEdlFiles = false;
|
||||||
@ -163,10 +194,12 @@ public class BaseItemAnalyzerTask
|
|||||||
/// Analyze a group of media items for skippable segments.
|
/// Analyze a group of media items for skippable segments.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="items">Media items to analyze.</param>
|
/// <param name="items">Media items to analyze.</param>
|
||||||
|
/// <param name="mode">Analysis mode.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>Number of items that were successfully analyzed.</returns>
|
/// <returns>Number of items that were successfully analyzed.</returns>
|
||||||
private int AnalyzeItems(
|
private int AnalyzeItems(
|
||||||
ReadOnlyCollection<QueuedEpisode> items,
|
ReadOnlyCollection<QueuedEpisode> items,
|
||||||
|
AnalysisMode mode,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var totalItems = items.Count;
|
var totalItems = items.Count;
|
||||||
@ -179,7 +212,8 @@ public class BaseItemAnalyzerTask
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Analyzing {Count} files from {Name} season {Season}",
|
"[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}",
|
||||||
|
mode,
|
||||||
items.Count,
|
items.Count,
|
||||||
first.SeriesName,
|
first.SeriesName,
|
||||||
first.SeasonNumber);
|
first.SeasonNumber);
|
||||||
@ -187,21 +221,21 @@ public class BaseItemAnalyzerTask
|
|||||||
var analyzers = new Collection<IMediaFileAnalyzer>();
|
var analyzers = new Collection<IMediaFileAnalyzer>();
|
||||||
|
|
||||||
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
|
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
|
||||||
|
if (mode == AnalysisMode.Credits)
|
||||||
|
{
|
||||||
|
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
|
||||||
|
}
|
||||||
|
|
||||||
if (Plugin.Instance!.Configuration.UseChromaprint)
|
if (Plugin.Instance!.Configuration.UseChromaprint)
|
||||||
{
|
{
|
||||||
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
|
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._analysisMode == AnalysisMode.Credits)
|
|
||||||
{
|
|
||||||
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use each analyzer to find skippable ranges in all media files, removing successfully
|
// Use each analyzer to find skippable ranges in all media files, removing successfully
|
||||||
// analyzed items from the queue.
|
// analyzed items from the queue.
|
||||||
foreach (var analyzer in analyzers)
|
foreach (var analyzer in analyzers)
|
||||||
{
|
{
|
||||||
items = analyzer.AnalyzeMediaFiles(items, this._analysisMode, cancellationToken);
|
items = analyzer.AnalyzeMediaFiles(items, mode, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalItems;
|
return totalItems;
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
@ -14,6 +18,8 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DetectCreditsTask : IScheduledTask
|
public class DetectCreditsTask : IScheduledTask
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<DetectCreditsTask> _logger;
|
||||||
|
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
|
||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
@ -23,10 +29,13 @@ public class DetectCreditsTask : IScheduledTask
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="loggerFactory">Logger factory.</param>
|
/// <param name="loggerFactory">Logger factory.</param>
|
||||||
/// <param name="libraryManager">Library manager.</param>
|
/// <param name="libraryManager">Library manager.</param>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
public DetectCreditsTask(
|
public DetectCreditsTask(
|
||||||
|
ILogger<DetectCreditsTask> logger,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
ILibraryManager libraryManager)
|
ILibraryManager libraryManager)
|
||||||
{
|
{
|
||||||
|
_logger = logger;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
}
|
}
|
||||||
@ -44,7 +53,7 @@ public class DetectCreditsTask : IScheduledTask
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the task description.
|
/// Gets the task description.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Description => "Analyzes the audio and video of all television episodes to find credits.";
|
public string Description => "Analyzes media to determine the timestamp and length of credits";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the task key.
|
/// Gets the task key.
|
||||||
@ -64,14 +73,35 @@ public class DetectCreditsTask : IScheduledTask
|
|||||||
throw new InvalidOperationException("Library manager was null");
|
throw new InvalidOperationException("Library manager was null");
|
||||||
}
|
}
|
||||||
|
|
||||||
var baseAnalyzer = new BaseItemAnalyzerTask(
|
// abort automatic analyzer if running
|
||||||
AnalysisMode.Credits,
|
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.CreateLogger<DetectCreditsTask>(),
|
||||||
_loggerFactory,
|
_loggerFactory,
|
||||||
_libraryManager);
|
_libraryManager);
|
||||||
|
|
||||||
baseAnalyzer.AnalyzeItems(progress, cancellationToken);
|
baseCreditAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||||
|
|
||||||
|
ScheduledTaskSemaphore.Release();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
@ -11,21 +15,26 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Analyze all television episodes for introduction sequences.
|
/// Analyze all television episodes for introduction sequences.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DetectIntroductionsTask : IScheduledTask
|
public class DetectIntrosCreditsTask : IScheduledTask
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<DetectIntrosCreditsTask> _logger;
|
||||||
|
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
|
||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="DetectIntroductionsTask"/> class.
|
/// Initializes a new instance of the <see cref="DetectIntrosCreditsTask"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="loggerFactory">Logger factory.</param>
|
/// <param name="loggerFactory">Logger factory.</param>
|
||||||
/// <param name="libraryManager">Library manager.</param>
|
/// <param name="libraryManager">Library manager.</param>
|
||||||
public DetectIntroductionsTask(
|
/// <param name="logger">Logger.</param>
|
||||||
|
public DetectIntrosCreditsTask(
|
||||||
|
ILogger<DetectIntrosCreditsTask> logger,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
ILibraryManager libraryManager)
|
ILibraryManager libraryManager)
|
||||||
{
|
{
|
||||||
|
_logger = logger;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
}
|
}
|
||||||
@ -33,7 +42,7 @@ public class DetectIntroductionsTask : IScheduledTask
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the task name.
|
/// Gets the task name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name => "Detect Introductions";
|
public string Name => "Detect Intros and Credits";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the task category.
|
/// Gets the task category.
|
||||||
@ -43,12 +52,12 @@ public class DetectIntroductionsTask : IScheduledTask
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the task description.
|
/// Gets the task description.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Description => "Analyzes the audio of all television episodes to find introduction sequences.";
|
public string Description => "Analyzes media to determine the timestamp and length of intros and credits.";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the task key.
|
/// Gets the task key.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Key => "CPBIntroSkipperDetectIntroductions";
|
public string Key => "CPBIntroSkipperDetectIntrosCredits";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
|
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
|
||||||
@ -63,14 +72,35 @@ public class DetectIntroductionsTask : IScheduledTask
|
|||||||
throw new InvalidOperationException("Library manager was null");
|
throw new InvalidOperationException("Library manager was null");
|
||||||
}
|
}
|
||||||
|
|
||||||
var baseAnalyzer = new BaseItemAnalyzerTask(
|
// abort automatic analyzer if running
|
||||||
AnalysisMode.Introduction,
|
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
|
||||||
_loggerFactory.CreateLogger<DetectIntroductionsTask>(),
|
{
|
||||||
|
_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,
|
_loggerFactory,
|
||||||
_libraryManager);
|
_libraryManager);
|
||||||
|
|
||||||
baseAnalyzer.AnalyzeItems(progress, cancellationToken);
|
baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||||
|
|
||||||
|
ScheduledTaskSemaphore.Release();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
83
README.md
83
README.md
@ -1,5 +1,4 @@
|
|||||||
# Intro Skipper (beta)
|
# Intro Skipper (beta)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<p>
|
<p>
|
||||||
<img alt="Plugin Banner" src="https://raw.githubusercontent.com/jumoog/intro-skipper/master/images/logo.png" />
|
<img alt="Plugin Banner" src="https://raw.githubusercontent.com/jumoog/intro-skipper/master/images/logo.png" />
|
||||||
@ -9,6 +8,12 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
## Manifest URL (All Jellyfin Versions)
|
||||||
|
|
||||||
|
```
|
||||||
|
https://manifest.intro-skipper.org/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
## System requirements
|
## System requirements
|
||||||
|
|
||||||
* Jellyfin 10.8.4 (or newer)
|
* Jellyfin 10.8.4 (or newer)
|
||||||
@ -16,73 +21,25 @@
|
|||||||
* `jellyfin/jellyfin` 10.8.z container: preinstalled
|
* `jellyfin/jellyfin` 10.8.z container: preinstalled
|
||||||
* `linuxserver/jellyfin` 10.8.z container: preinstalled
|
* `linuxserver/jellyfin` 10.8.z container: preinstalled
|
||||||
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg5` package
|
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg5` package
|
||||||
* MacOS native installs: build ffmpeg with chromaprint support ([instructions](#installation-instructions-for-macos))
|
* MacOS native installs: build ffmpeg with chromaprint support ([instructions](https://github.com/jumoog/intro-skipper/wiki/Custom-FFMPEG-(MacOS)))
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
## Detection parameters
|
* SyncPlay is not (yet) compatible with any method of skipping due to the nature of how the clients are synced.
|
||||||
|
|
||||||
Show introductions will be detected if they are:
|
## [Detection parameters](https://github.com/intro-skipper/intro-skipper/wiki#detection-parameters)
|
||||||
|
|
||||||
* Located within the first 25% of an episode or the first 10 minutes, whichever is smaller
|
## [Detection types](https://github.com/intro-skipper/intro-skipper/wiki#detection-types)
|
||||||
* Between 15 seconds and 2 minutes long
|
|
||||||
|
|
||||||
Ending credits will be detected if they are shorter than 4 minutes.
|
## [Installation](https://github.com/intro-skipper/intro-skipper/wiki/Installation)
|
||||||
|
|
||||||
These parameters can be configured by opening the plugin settings
|
## [Jellyfin Skip Options](https://github.com/intro-skipper/intro-skipper/wiki/Jellyfin-Skip-Options)
|
||||||
|
|
||||||
## Installation
|
## [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting)
|
||||||
|
|
||||||
### Step 1: Install the plugin
|
## [API Documentation](https://github.com/intro-skipper/intro-skipper/blob/master/docs/api.md)
|
||||||
1. Add this plugin repository to your server: `https://raw.githubusercontent.com/jumoog/intro-skipper/master/manifest.json`
|
|
||||||
2. Install the Intro Skipper plugin from the General section
|
|
||||||
3. Restart Jellyfin
|
|
||||||
### Step 2: Configure the plugin
|
|
||||||
4. OPTIONAL: Enable automatic skipping or skip button
|
|
||||||
1. Go to Dashboard -> Plugins -> Intro Skipper
|
|
||||||
2. Check "Automatically skip intros" or "Show skip intro button" and click Save
|
|
||||||
5. Go to Dashboard -> Scheduled Tasks -> Analyze Episodes and click the play button
|
|
||||||
6. After a season has completed analyzing, play some episodes from it and observe the results
|
|
||||||
1. Status updates are logged before analyzing each season of a show
|
|
||||||
|
|
||||||
## Troubleshooting
|
<br />
|
||||||
#### Scheduled tasks fail instantly
|
<p align="center">
|
||||||
- Verify that Intro Skipper can detect ffmpeg with Chromaprint
|
<a href="https://discord.gg/AYZ7RJ3BuA"><img src="https://invidget.switchblade.xyz/AYZ7RJ3BuA"></a>
|
||||||
- Dashboard -> Plugins -> Intro Skipper -> Support Bundle Info
|
</p>
|
||||||
- Verify that ffmpeg is installed and detected by jellyfin
|
|
||||||
- Dashboard -> Playback -> FFmpeg path
|
|
||||||
- Verify that Chromaprint is enabled in ffmpeg (`--enable-chromaprint`)
|
|
||||||
|
|
||||||
#### Skip button is not visible
|
|
||||||
- Verify you have successfully completed the scheduled task at least once
|
|
||||||
- Clear your browser cache and reload the Jellyfin server webpage
|
|
||||||
- Fix any permission mismatches between the web folder and Jellyfin server
|
|
||||||
|
|
||||||
* <b>Docker -</b> the container is being run as a non-root user while having been built as a root user, causing the web files to be owned by root. To solve this, you can remove any lines like `User: 1000:1000`, `GUID:`, `PID:`, etc. from the jellyfin docker compose file.
|
|
||||||
|
|
||||||
* <b>Install from distro repositories -</b> the jellyfin-server will execute as `jellyfin` user while the web files will be owned by `root`, `www-data`, etc. This can <i>likely</i> be fixed by adding the `jellyfin` user (or whichever user executes the jellyfin server) to the same group that owns the jellyfin-web folders. **You should only do this if they are owned by a group other than root**.
|
|
||||||
|
|
||||||
## Installation (MacOS)
|
|
||||||
|
|
||||||
1. Build ffmpeg with chromaprint support using brew:
|
|
||||||
- macOS 12 or newer can install the [portable jellyfin-ffmpeg](https://github.com/jellyfin/jellyfin-ffmpeg)
|
|
||||||
|
|
||||||
```
|
|
||||||
brew uninstall --force --ignore-dependencies ffmpeg
|
|
||||||
brew install chromaprint amiaopensource/amiaos/decklinksdk
|
|
||||||
brew tap homebrew-ffmpeg/ffmpeg
|
|
||||||
brew install homebrew-ffmpeg/ffmpeg/ffmpeg --with-chromaprint
|
|
||||||
brew link --overwrite ffmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Open ~/.config/jellyfin/encoding.xml and add or edit the following lines
|
|
||||||
- Replace [FFMPEG_PATH] with the path returned by `whereis ffmpeg`
|
|
||||||
|
|
||||||
```
|
|
||||||
<EncoderAppPath>[FFMPEG_PATH]</EncoderAppPath>
|
|
||||||
<EncoderAppPathDisplay>[FFMPEG_PATH]</EncoderAppPathDisplay>
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Follow the [general installation instructions](#installation) above
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Documentation about how the API works can be found in [api.md](docs/api.md).
|
|
||||||
|
@ -4,73 +4,17 @@
|
|||||||
"name": "Intro Skipper",
|
"name": "Intro Skipper",
|
||||||
"overview": "Automatically detect and skip intros in television episodes",
|
"overview": "Automatically detect and skip intros in television episodes",
|
||||||
"description": "Analyzes the audio of television episodes and detects introduction sequences.",
|
"description": "Analyzes the audio of television episodes and detects introduction sequences.",
|
||||||
"owner": "jumoog, AbandonedCart (forked from ConfusedPolarBear)",
|
"owner": "AbandonedCart, rlauuzo, jumoog (forked from ConfusedPolarBear)",
|
||||||
"category": "General",
|
"category": "General",
|
||||||
"imageUrl": "https://raw.githubusercontent.com/jumoog/intro-skipper/master/images/logo.png",
|
"imageUrl": "https://raw.githubusercontent.com/jumoog/intro-skipper/master/images/logo.png",
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
|
||||||
"version": "0.1.16.3",
|
|
||||||
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
|
|
||||||
"targetAbi": "10.8.4.0",
|
|
||||||
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.16.3/intro-skipper-v0.1.16.3.zip",
|
|
||||||
"checksum": "f4832c852684409206e0f4191a135779",
|
|
||||||
"timestamp": "2024-03-11T22:00:13Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.16.2",
|
|
||||||
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
|
|
||||||
"targetAbi": "10.8.4.0",
|
|
||||||
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.16.2/intro-skipper-v0.1.16.2.zip",
|
|
||||||
"checksum": "714e084cb02d159da57216e2ceec3509",
|
|
||||||
"timestamp": "2024-03-07T21:20:34Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.16.1",
|
|
||||||
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
|
|
||||||
"targetAbi": "10.8.4.0",
|
|
||||||
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.16.1/intro-skipper-v0.1.16.1.zip",
|
|
||||||
"checksum": "d8e5370f974bd5624206f87b3fed05bb",
|
|
||||||
"timestamp": "2024-03-06T10:17:25Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "0.1.16.0",
|
"version": "0.10.8.1",
|
||||||
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
|
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
|
||||||
"targetAbi": "10.8.4.0",
|
"targetAbi": "10.8.4.0",
|
||||||
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.16/intro-skipper-v0.1.16.zip",
|
"sourceUrl": "https://github.com/intro-skipper/intro-skipper/releases/download/10.8/v0.10.8.1/intro-skipper-v0.10.8.1.zip",
|
||||||
"checksum": "8989cbe9c438d5a14fab3002e21c26ba",
|
"checksum": "76cdb2bad9582d23c1f6f4d868218d6c",
|
||||||
"timestamp": "2024-03-04T10:10:35Z"
|
"timestamp": "2024-10-26T18:10:00Z"
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.14.0",
|
|
||||||
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
|
|
||||||
"targetAbi": "10.8.4.0",
|
|
||||||
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.14/intro-skipper-v0.1.14.zip",
|
|
||||||
"checksum": "704ecc32588243545c44b2eed130b033",
|
|
||||||
"timestamp": "2024-03-02T21:10:57Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.10.0",
|
|
||||||
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
|
|
||||||
"targetAbi": "10.8.4.0",
|
|
||||||
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.10/intro-skipper-v0.1.10.zip",
|
|
||||||
"checksum": "50b16e5131f3389d7261691c301c2d70",
|
|
||||||
"timestamp": "2024-03-01T16:50:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.9.0",
|
|
||||||
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
|
|
||||||
"targetAbi": "10.8.4.0",
|
|
||||||
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.9/intro-skipper-v0.1.9.zip",
|
|
||||||
"checksum": "a85ff99d7fcde37aecf3fdd355708f49",
|
|
||||||
"timestamp": "2024-03-01T10:23:43Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.8.0",
|
|
||||||
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
|
|
||||||
"targetAbi": "10.8.4.0",
|
|
||||||
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.7/intro-skipper-v0.1.7.zip",
|
|
||||||
"checksum": "97a0208376adbdd1ebb17b4ce358ab9c",
|
|
||||||
"timestamp": "2022-10-27T03:27:27Z"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
58
update-version.js
Normal file
58
update-version.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Read csproj
|
||||||
|
const csprojPath = './ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj';
|
||||||
|
if (!fs.existsSync(csprojPath)) {
|
||||||
|
console.error('ConfusedPolarBear.Plugin.IntroSkipper.csproj file not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCsprojVersion() {
|
||||||
|
const newVersion = process.env.VERSION
|
||||||
|
const csprojContent = fs.readFileSync(csprojPath, 'utf8');
|
||||||
|
|
||||||
|
const updatedContent = csprojContent
|
||||||
|
.replace(/<AssemblyVersion>.*<\/AssemblyVersion>/, `<AssemblyVersion>${newVersion}</AssemblyVersion>`)
|
||||||
|
.replace(/<FileVersion>.*<\/FileVersion>/, `<FileVersion>${newVersion}</FileVersion>`);
|
||||||
|
|
||||||
|
fs.writeFileSync(csprojPath, updatedContent);
|
||||||
|
console.log('Updated .csproj file with new version.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to increment version string
|
||||||
|
function incrementVersion(version) {
|
||||||
|
const parts = version.split('.').map(Number);
|
||||||
|
parts[parts.length - 1] += 1; // Increment the last part of the version
|
||||||
|
return parts.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the .csproj file
|
||||||
|
fs.readFile(csprojPath, 'utf8', (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
return console.error('Failed to read .csproj file:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newAssemblyVersion = null;
|
||||||
|
let newFileVersion = null;
|
||||||
|
|
||||||
|
// Use regex to find and increment versions
|
||||||
|
const updatedData = data.replace(/<AssemblyVersion>(.*?)<\/AssemblyVersion>/, (match, version) => {
|
||||||
|
newAssemblyVersion = incrementVersion(version);
|
||||||
|
return `<AssemblyVersion>${newAssemblyVersion}</AssemblyVersion>`;
|
||||||
|
}).replace(/<FileVersion>(.*?)<\/FileVersion>/, (match, version) => {
|
||||||
|
newFileVersion = incrementVersion(version);
|
||||||
|
return `<FileVersion>${newFileVersion}</FileVersion>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write the updated XML back to the .csproj file
|
||||||
|
fs.writeFile(csprojPath, updatedData, 'utf8', (err) => {
|
||||||
|
if (err) {
|
||||||
|
return console.error('Failed to write .csproj file:', err);
|
||||||
|
}
|
||||||
|
console.log('Version incremented successfully!');
|
||||||
|
|
||||||
|
// Write the new versions to GitHub Actions environment files
|
||||||
|
fs.appendFileSync(process.env.GITHUB_ENV, `NEW_ASSEMBLY_VERSION=${newAssemblyVersion}\n`);
|
||||||
|
fs.appendFileSync(process.env.GITHUB_ENV, `NEW_FILE_VERSION=${newFileVersion}\n`);
|
||||||
|
});
|
||||||
|
});
|
110
validate-and-update-manifest.js
Normal file
110
validate-and-update-manifest.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
const https = require('https');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { URL } = require('url');
|
||||||
|
|
||||||
|
// Read manifest.json
|
||||||
|
const manifestPath = './manifest.json';
|
||||||
|
if (!fs.existsSync(manifestPath)) {
|
||||||
|
console.error('manifest.json file not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const jsonData = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
|
|
||||||
|
const newVersion = {
|
||||||
|
version: process.env.VERSION, // replace with the actual new version
|
||||||
|
changelog: "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
|
||||||
|
targetAbi: "10.8.4.0",
|
||||||
|
sourceUrl: process.env.SOURCE_URL,
|
||||||
|
checksum: process.env.CHECKSUM,
|
||||||
|
timestamp: process.env.TIMESTAMP
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateManifest() {
|
||||||
|
await validVersion(newVersion);
|
||||||
|
|
||||||
|
// Add the new version to the manifest
|
||||||
|
jsonData[0].versions.unshift(newVersion);
|
||||||
|
|
||||||
|
// Write the updated manifest to file if validation is successful
|
||||||
|
fs.writeFileSync(manifestPath, JSON.stringify(jsonData, null, 4));
|
||||||
|
console.log('Manifest updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validVersion(version) {
|
||||||
|
console.log(`Validating version ${version.version}...`);
|
||||||
|
|
||||||
|
const isValidUrl = await checkUrl(version.sourceUrl);
|
||||||
|
if (!isValidUrl) {
|
||||||
|
console.error(`Invalid URL: ${version.sourceUrl}`);
|
||||||
|
process.exit(1); // Exit with an error code
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidChecksum = await verifyChecksum(version.sourceUrl, version.checksum);
|
||||||
|
if (!isValidChecksum) {
|
||||||
|
console.error(`Checksum mismatch for URL: ${version.sourceUrl}`);
|
||||||
|
process.exit(1); // Exit with an error code
|
||||||
|
} else {
|
||||||
|
console.log(`Version ${version.version} is valid.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkUrl(url) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
https.get(url, (res) => {
|
||||||
|
resolve(res.statusCode === 302);
|
||||||
|
}).on('error', () => {
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyChecksum(url, expectedChecksum) {
|
||||||
|
const tempFilePath = `tempfile_${Math.random().toString(36).substring(2, 15) + Math.random().toString(23).substring(2, 5)}.zip`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadFile(url, tempFilePath);
|
||||||
|
const fileBuffer = fs.readFileSync(tempFilePath);
|
||||||
|
const hash = crypto.createHash('md5').update(fileBuffer).digest('hex');
|
||||||
|
fs.unlinkSync(tempFilePath); // Clean up temp file
|
||||||
|
return hash === expectedChecksum;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error verifying checksum for URL: ${url}`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(url, destinationPath, redirects = 5) {
|
||||||
|
if (redirects === 0) {
|
||||||
|
throw new Error('Too many redirects');
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = fs.createWriteStream(destinationPath);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(url, (response) => {
|
||||||
|
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
||||||
|
// Follow redirect
|
||||||
|
const redirectUrl = new URL(response.headers.location, url).toString();
|
||||||
|
downloadFile(redirectUrl, destinationPath, redirects - 1)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
} else if (response.statusCode === 200) {
|
||||||
|
response.pipe(file);
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close(resolve);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Failed to get '${url}' (${response.statusCode})`));
|
||||||
|
}
|
||||||
|
}).on('error', (err) => {
|
||||||
|
fs.unlink(destinationPath, () => reject(err));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
await updateManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
Loading…
x
Reference in New Issue
Block a user