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
|
||||
csharp_preserve_single_line_statements = true
|
||||
csharp_preserve_single_line_blocks = true
|
||||
|
||||
file_header_template = Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>\nSPDX-License-Identifier: GPL-3.0-only.
|
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]: "
|
||||
labels: [bug]
|
||||
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
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
@ -22,24 +34,33 @@ body:
|
||||
|
||||
- type: input
|
||||
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.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Container image and tag
|
||||
description: Only fill in this field if you are running Jellyfin in a container
|
||||
label: Container image/tag or Jellyfin version
|
||||
description: The container for Docker or Jellyfin version for a native install
|
||||
placeholder: jellyfin/jellyfin:10.8.7, jellyfin-intro-skipper:latest, etc.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: The operating system of the Jellyfin / Docker host computer
|
||||
placeholder: Debian 11, Windows 11, etc.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: IMDb ID of that TV Series
|
||||
placeholder: tt0903747
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Support Bundle
|
||||
|
67
.github/workflows/build.yml
vendored
67
.github/workflows/build.yml
vendored
@ -2,16 +2,28 @@ name: 'Build Plugin'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
branches: [ "10.8" ]
|
||||
paths-ignore:
|
||||
- '**/README.md'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'manifest.json'
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
branches: [ "10.8" ]
|
||||
paths-ignore:
|
||||
- '**/README.md'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'manifest.json'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ ! startsWith(github.event.head_commit.message, 'v0.1') }}
|
||||
if: ${{ ! startsWith(github.event.head_commit.message, 'v0.') }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@ -23,6 +35,20 @@ jobs:
|
||||
with:
|
||||
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
|
||||
run: dotnet restore
|
||||
|
||||
@ -38,12 +64,22 @@ jobs:
|
||||
run: dotnet build --no-restore
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4.3.1
|
||||
uses: actions/upload-artifact@v4.3.3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.GIT_HASH }}.dll
|
||||
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4.3.3
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ github.head_ref }}.dll
|
||||
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create archive
|
||||
uses: vimtor/action-zip@v1.2
|
||||
if: github.event_name != 'pull_request'
|
||||
@ -54,16 +90,33 @@ jobs:
|
||||
|
||||
- name: Generate md5
|
||||
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
|
||||
uses: 8bitDream/action-github-releases@v1.0.0
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
automatic_release_tag: preview
|
||||
automatic_release_tag: 10.8/preview
|
||||
prerelease: true
|
||||
title: intro-skipper-${{ env.GIT_HASH }}
|
||||
files: |
|
||||
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:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version v'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@ -19,25 +14,47 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
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
|
||||
run: dotnet restore
|
||||
|
||||
- name: Run update version
|
||||
run: node update-version.js
|
||||
|
||||
- name: Embed version info
|
||||
run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --no-restore
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4.3.1
|
||||
uses: actions/upload-artifact@v4.3.3
|
||||
with:
|
||||
name: ConfusedPolarBear.Plugin.IntroSkipper-v${{ github.event.inputs.version }}.dll
|
||||
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||
name: ConfusedPolarBear.Plugin.IntroSkipper-v${{ env.NEW_FILE_VERSION }}.dll
|
||||
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Release/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create archive
|
||||
@ -45,28 +62,48 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
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: |
|
||||
md5sum intro-skipper-v${{ github.event.inputs.version }}.zip > intro-skipper-v${{ github.event.inputs.version }}.zip.md5
|
||||
echo "sourceUrl: https://github.com/${{ github.repository }}/releases/download/v${{ github.event.inputs.version }}/intro-skipper-v${{ github.event.inputs.version }}.zip"
|
||||
echo "checksum: $(awk '{print $1}' intro-skipper-v${{ github.event.inputs.version }}.zip.md5)"
|
||||
echo "timestamp: $(date +%FT%TZ)"
|
||||
sourceUrl="https://github.com/${{ github.repository }}/releases/download/10.8/v${{ env.NEW_FILE_VERSION }}/intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip"
|
||||
echo "SOURCE_URL=$sourceUrl" >> $GITHUB_ENV
|
||||
md5sum intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip > intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5
|
||||
checksum="$(awk '{print $1}' intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5)"
|
||||
echo "CHECKSUM=$checksum" >> $GITHUB_ENV
|
||||
timestamp="$(date +%FT%TZ)"
|
||||
echo "TIMESTAMP=$timestamp" >> $GITHUB_ENV
|
||||
|
||||
- name: Publish release
|
||||
uses: 8bitDream/action-github-releases@v1.0.0
|
||||
with:
|
||||
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
|
||||
title: v${{ github.event.inputs.version }}
|
||||
title: v${{ env.NEW_FILE_VERSION }}
|
||||
files: |
|
||||
intro-skipper-v${{ github.event.inputs.version }}.zip
|
||||
intro-skipper-v${{ github.event.inputs.version }}.zip.md5
|
||||
intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip
|
||||
|
||||
# - name: Push changes
|
||||
# uses: ad-m/github-push-action@master
|
||||
# with:
|
||||
# github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# branch: master
|
||||
- name: Publish release notes
|
||||
uses: softprops/action-gh-release@v2.0.5
|
||||
with:
|
||||
tag_name: 10.8/v${{ env.NEW_FILE_VERSION }}
|
||||
name: v${{ env.NEW_FILE_VERSION }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
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
|
||||
.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
|
||||
* which supports both chromaprint and the "-fp_format raw" flag.
|
||||
*/
|
||||
@ -81,7 +84,7 @@ public class TestAudioFingerprinting
|
||||
{77, 5},
|
||||
};
|
||||
|
||||
var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr);
|
||||
var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
@ -104,11 +107,12 @@ public class TestAudioFingerprinting
|
||||
|
||||
Assert.True(lhs.Valid);
|
||||
Assert.Equal(0, lhs.IntroStart);
|
||||
Assert.Equal(17.792, lhs.IntroEnd);
|
||||
Assert.Equal(17.208, lhs.IntroEnd, 3);
|
||||
|
||||
Assert.True(rhs.Valid);
|
||||
Assert.Equal(5.12, rhs.IntroStart);
|
||||
Assert.Equal(22.912, rhs.IntroEnd);
|
||||
// because we changed for 0.128 to 0.1238 its 4,952 now but that's too early (<= 5)
|
||||
Assert.Equal(0, rhs.IntroStart);
|
||||
Assert.Equal(22.1602, rhs.IntroEnd);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
using System;
|
||||
@ -13,7 +16,7 @@ public class TestBlackFrames
|
||||
var range = 1e-5;
|
||||
|
||||
var expected = new List<BlackFrame>();
|
||||
expected.AddRange(CreateFrameSequence(2, 3));
|
||||
expected.AddRange(CreateFrameSequence(2.04, 3));
|
||||
expected.AddRange(CreateFrameSequence(5, 6));
|
||||
expected.AddRange(CreateFrameSequence(8, 9.96));
|
||||
|
||||
@ -30,14 +33,15 @@ public class TestBlackFrames
|
||||
[FactSkipFFmpegTests]
|
||||
public void TestEndCreditDetection()
|
||||
{
|
||||
var range = 1;
|
||||
// new strategy new range
|
||||
var range = 3;
|
||||
|
||||
var analyzer = CreateBlackFrameAnalyzer();
|
||||
|
||||
var episode = queueFile("credits.mp4");
|
||||
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.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;
|
||||
|
||||
using System;
|
||||
|
@ -1,3 +1,6 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using Xunit;
|
||||
|
||||
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 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;
|
||||
|
||||
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;
|
||||
|
||||
using System;
|
||||
@ -17,12 +20,23 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
|
||||
private readonly ILogger<BlackFrameAnalyzer> _logger;
|
||||
|
||||
private int minimumCreditsDuration;
|
||||
|
||||
private int maximumCreditsDuration;
|
||||
|
||||
private int blackFrameMinimumPercentage;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BlackFrameAnalyzer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger)
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
|
||||
minimumCreditsDuration = config.MinimumCreditsDuration;
|
||||
maximumCreditsDuration = 2 * config.MaximumCreditsDuration;
|
||||
blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage;
|
||||
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@ -39,6 +53,12 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
|
||||
var creditTimes = new Dictionary<Guid, Intro>();
|
||||
|
||||
bool isFirstEpisode = true;
|
||||
|
||||
double searchStart = minimumCreditsDuration;
|
||||
|
||||
var searchDistance = 2 * minimumCreditsDuration;
|
||||
|
||||
foreach (var episode in analysisQueue)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
@ -46,16 +66,54 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
break;
|
||||
}
|
||||
|
||||
// Pre-check to find reasonable starting point.
|
||||
if (isFirstEpisode)
|
||||
{
|
||||
var scanTime = episode.Duration - searchStart;
|
||||
var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here.
|
||||
|
||||
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, blackFrameMinimumPercentage);
|
||||
|
||||
while (frames.Length > 0) // While black frames are found increase searchStart
|
||||
{
|
||||
searchStart += searchDistance;
|
||||
|
||||
scanTime = episode.Duration - searchStart;
|
||||
tr = new TimeRange(scanTime - 0.5, scanTime);
|
||||
|
||||
frames = FFmpegWrapper.DetectBlackFrames(episode, tr, blackFrameMinimumPercentage);
|
||||
|
||||
if (searchStart > maximumCreditsDuration)
|
||||
{
|
||||
searchStart = maximumCreditsDuration;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchStart == minimumCreditsDuration) // Skip if no black frames were found
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
isFirstEpisode = false;
|
||||
}
|
||||
|
||||
var intro = AnalyzeMediaFile(
|
||||
episode,
|
||||
mode,
|
||||
Plugin.Instance!.Configuration.BlackFrameMinimumPercentage);
|
||||
searchStart,
|
||||
searchDistance,
|
||||
blackFrameMinimumPercentage);
|
||||
|
||||
if (intro is null)
|
||||
{
|
||||
// If no credits were found, reset the first-episode search logic for the next episode in the sequence.
|
||||
searchStart = minimumCreditsDuration;
|
||||
isFirstEpisode = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
searchStart = episode.Duration - intro.IntroStart + (0.5 * searchDistance);
|
||||
|
||||
creditTimes[episode.EpisodeId] = intro;
|
||||
}
|
||||
|
||||
@ -71,16 +129,17 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
/// Analyzes an individual media file. Only public because of unit tests.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// <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.
|
||||
var start = TimeSpan.FromSeconds(config.MaximumEpisodeCreditsDuration);
|
||||
var end = TimeSpan.FromSeconds(config.MinimumCreditsDuration);
|
||||
var upperLimit = searchStart;
|
||||
var lowerLimit = Math.Max(searchStart - searchDistance, minimumCreditsDuration);
|
||||
var start = TimeSpan.FromSeconds(upperLimit);
|
||||
var end = TimeSpan.FromSeconds(lowerLimit);
|
||||
var firstFrameTime = 0.0;
|
||||
|
||||
// Continue bisecting the end of the file until the range that contains the first black
|
||||
@ -111,13 +170,29 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
if (frames.Length == 0)
|
||||
{
|
||||
// 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
|
||||
{
|
||||
// Some black frames were found, slide the range closer to the start
|
||||
end = midpoint;
|
||||
firstFrameTime = frames[0].Time + scanTime;
|
||||
|
||||
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError)
|
||||
{
|
||||
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), maximumCreditsDuration);
|
||||
|
||||
// Reset start for a new search with the increased duration
|
||||
start = TimeSpan.FromSeconds(upperLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
|
||||
using System;
|
||||
@ -91,10 +94,12 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
|
||||
|
||||
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 ?
|
||||
config.MaximumIntroDuration :
|
||||
config.MaximumEpisodeCreditsDuration;
|
||||
config.MaximumCreditsDuration;
|
||||
|
||||
if (chapters.Count == 0)
|
||||
{
|
||||
@ -111,10 +116,10 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
|
||||
});
|
||||
|
||||
// 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 next = chapters[i + 1];
|
||||
var previous = chapters[i - 1];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(current.Name))
|
||||
{
|
||||
@ -123,7 +128,7 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
|
||||
|
||||
var currentRange = new TimeRange(
|
||||
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
|
||||
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
|
||||
TimeSpan.FromTicks(chapters[i + 1].StartPositionTicks).TotalSeconds);
|
||||
|
||||
var baseMessage = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
@ -153,6 +158,21 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(previous.Name))
|
||||
{
|
||||
// Check for possibility of overlapping keywords
|
||||
var overlap = Regex.IsMatch(
|
||||
previous.Name,
|
||||
expression,
|
||||
RegexOptions.None,
|
||||
TimeSpan.FromSeconds(1));
|
||||
|
||||
if (overlap)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
matchingChapter = new(episode.EpisodeId, currentRange);
|
||||
_logger.LogTrace("{Base}: okay", baseMessage);
|
||||
break;
|
||||
|
@ -1,3 +1,6 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
|
||||
using System;
|
||||
@ -16,7 +19,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
/// Seconds of audio in one fingerprint point.
|
||||
/// This value is defined by the Chromaprint library and should not be changed.
|
||||
/// </summary>
|
||||
private const double SamplesToSeconds = 0.128;
|
||||
private const double SamplesToSeconds = 0.1238;
|
||||
|
||||
private int minimumIntroDuration;
|
||||
|
||||
@ -75,6 +78,12 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
{
|
||||
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode);
|
||||
|
||||
// Use reversed fingerprints for credits
|
||||
if (_analysisMode == AnalysisMode.Credits)
|
||||
{
|
||||
Array.Reverse(fingerprintCache[episode.EpisodeId]);
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return analysisQueue;
|
||||
@ -123,14 +132,20 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
* 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.
|
||||
*
|
||||
* 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)
|
||||
{
|
||||
currentIntro.IntroStart += currentEpisode.CreditsFingerprintStart;
|
||||
currentIntro.IntroEnd += currentEpisode.CreditsFingerprintStart;
|
||||
remainingIntro.IntroStart += remainingEpisode.CreditsFingerprintStart;
|
||||
remainingIntro.IntroEnd += remainingEpisode.CreditsFingerprintStart;
|
||||
// Calculate new values for the current intro
|
||||
double currentOriginalIntroStart = currentIntro.IntroStart;
|
||||
currentIntro.IntroStart = currentEpisode.Duration - currentIntro.IntroEnd;
|
||||
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:
|
||||
@ -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.
|
||||
episodesWithoutIntros.Add(currentEpisode);
|
||||
if (!seasonIntros.ContainsKey(currentEpisode.EpisodeId))
|
||||
{
|
||||
episodesWithoutIntros.Add(currentEpisode);
|
||||
}
|
||||
}
|
||||
|
||||
// If cancellation was requested, report that no episodes were analyzed.
|
||||
@ -261,8 +279,8 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
|
||||
var rhsRanges = new List<TimeRange>();
|
||||
|
||||
// Generate inverted indexes for the left and right episodes.
|
||||
var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints);
|
||||
var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints);
|
||||
var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, this._analysisMode);
|
||||
var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, this._analysisMode);
|
||||
var indexShifts = new HashSet<int>();
|
||||
|
||||
// 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;
|
||||
|
||||
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;
|
||||
|
||||
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.Collections.Generic;
|
||||
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 (!Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
|
||||
if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
|
||||
{
|
||||
newState = true;
|
||||
}
|
||||
@ -149,15 +152,16 @@ public class AutoSkip : IServerEntryPoint
|
||||
}
|
||||
|
||||
// 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(
|
||||
"Playback position is {Position}, intro runs from {Start} to {End}",
|
||||
position,
|
||||
adjustedStart,
|
||||
intro.IntroEnd);
|
||||
adjustedEnd);
|
||||
|
||||
if (position < adjustedStart || position > intro.IntroEnd)
|
||||
if (position < adjustedStart || position > adjustedEnd)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@ -180,8 +184,6 @@ public class AutoSkip : IServerEntryPoint
|
||||
|
||||
_logger.LogDebug("Sending seek command to {Session}", deviceId);
|
||||
|
||||
var introEnd = (long)intro.IntroEnd - Plugin.Instance!.Configuration.SecondsOfIntroToPlay;
|
||||
|
||||
_sessionManager.SendPlaystateCommand(
|
||||
session.Id,
|
||||
session.Id,
|
||||
@ -189,7 +191,7 @@ public class AutoSkip : IServerEntryPoint
|
||||
{
|
||||
Command = PlaystateCommand.Seek,
|
||||
ControllingUserId = session.UserId.ToString("N"),
|
||||
SeekPositionTicks = introEnd * TimeSpan.TicksPerSecond,
|
||||
SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond,
|
||||
},
|
||||
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 MediaBrowser.Model.Plugins;
|
||||
|
||||
@ -27,6 +31,21 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
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>
|
||||
/// Gets or sets a value indicating whether to analyze season 0.
|
||||
/// </summary>
|
||||
@ -86,7 +105,7 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// <summary>
|
||||
/// Gets or sets the upper limit (in seconds) on the length of each episode's audio track that will be analyzed when searching for ending credits.
|
||||
/// </summary>
|
||||
public int MaximumEpisodeCreditsDuration { get; set; } = 240;
|
||||
public int MaximumCreditsDuration { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string ChapterAnalyzerEndCreditsPattern { get; set; } =
|
||||
@"(^|\s)(Credits?|ED|Ending)(\s|$)";
|
||||
@"(^|\s)(Credits?|ED|Ending|End|Outro)(\s|$)";
|
||||
|
||||
// ===== Playback settings =====
|
||||
|
||||
@ -117,6 +136,11 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
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>
|
||||
/// Gets or sets the seconds before the intro starts to show the skip prompt at.
|
||||
/// </summary>
|
||||
@ -140,7 +164,17 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// <summary>
|
||||
/// Gets or sets the amount of intro to play (in seconds).
|
||||
/// </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 =====
|
||||
|
||||
@ -188,6 +222,11 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
public string AutoSkipNotificationText { get; set; } = "Intro skipped";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification text sent after automatically skipping credits.
|
||||
/// </summary>
|
||||
public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of threads for an ffmpeg process.
|
||||
/// </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;
|
||||
|
||||
/// <summary>
|
||||
|
@ -7,7 +7,7 @@
|
||||
|
||||
<body>
|
||||
<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">
|
||||
<style>
|
||||
summary {
|
||||
@ -27,6 +27,31 @@
|
||||
<fieldset class="verticalSection-extrabottompadding">
|
||||
<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">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" />
|
||||
@ -36,6 +61,7 @@
|
||||
<div class="fieldDescription">
|
||||
If checked, season 0 (specials / extras) will be included in analysis.
|
||||
<br />
|
||||
<br />
|
||||
Note: Shows containing both a specials and extra folder will identify extras as season 0
|
||||
and ignore specials, regardless of this setting.
|
||||
</div>
|
||||
@ -63,7 +89,7 @@
|
||||
</div>
|
||||
|
||||
<details id="intro_reqs">
|
||||
<summary>Modify Intro Parameters</summary>
|
||||
<summary>Modify Segment Parameters</summary>
|
||||
|
||||
<br />
|
||||
<div class="inputContainer">
|
||||
@ -111,6 +137,26 @@
|
||||
</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>
|
||||
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
|
||||
@ -121,7 +167,7 @@
|
||||
<p>
|
||||
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
|
||||
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.
|
||||
</p>
|
||||
@ -147,7 +193,7 @@
|
||||
</option>
|
||||
|
||||
<option value="Intro">
|
||||
Intro (show a skip button, *experimental*)
|
||||
Intro/Credit (show a skip button, *experimental*)
|
||||
</option>
|
||||
|
||||
<option value="Mute">
|
||||
@ -176,8 +222,8 @@
|
||||
</label>
|
||||
|
||||
<div class="fieldDescription">
|
||||
If checked, the plugin will <strong>overwrite all EDL files</strong> associated with
|
||||
your episodes with the currently discovered introduction timestamps and EDL action.
|
||||
If checked, the plugin will <b>overwrite all EDL files</b> associated with
|
||||
your episodes with the currently discovered introduction/credit timestamps and EDL action.
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@ -191,7 +237,7 @@
|
||||
Noise tolerance
|
||||
</label>
|
||||
<input id="SilenceDetectionMaximumNoise" type="number" is="emby-input" min="-90"
|
||||
max="0" />
|
||||
max="0" />
|
||||
<div class="fieldDescription">
|
||||
Noise tolerance in negative decibels.
|
||||
</div>
|
||||
@ -202,7 +248,7 @@
|
||||
Minimum silence duration
|
||||
</label>
|
||||
<input id="SilenceDetectionMinimumDuration" type="number" is="emby-input" min="0"
|
||||
step="0.01" />
|
||||
step="0.01" />
|
||||
<div class="fieldDescription">
|
||||
Minimum silence duration in seconds before adjusting introduction end time.
|
||||
</div>
|
||||
@ -212,7 +258,7 @@
|
||||
<details id="detection">
|
||||
<summary>Process Configuration</summary>
|
||||
|
||||
<br/>
|
||||
<br />
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="UseChromaprint" type="checkbox" is="emby-checkbox" />
|
||||
@ -222,7 +268,7 @@
|
||||
<div class="fieldDescription">
|
||||
If checked, analysis will use Chromaprint to compare episode audio and identify intros.
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
@ -236,7 +282,7 @@
|
||||
<div class="fieldDescription">
|
||||
If checked, episode fingerprints will be saved on the filesystem to improve analysis speed.
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
@ -280,7 +326,7 @@
|
||||
ffmpeg Threads
|
||||
</label>
|
||||
<input id="ProcessThreads" type="number" is="emby-input" min="0"
|
||||
max="16" />
|
||||
max="16" />
|
||||
<div class="fieldDescription">
|
||||
Number of simultaneous processes to use for ffmpeg operations.
|
||||
<br />
|
||||
@ -310,12 +356,48 @@
|
||||
<div id="divSkipFirstEpisode" class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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 class="checkboxContainer checkboxContainer-withDescription">
|
||||
@ -326,7 +408,7 @@
|
||||
|
||||
<div class="fieldDescription">
|
||||
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 />
|
||||
</div>
|
||||
</div>
|
||||
@ -338,7 +420,7 @@
|
||||
</label>
|
||||
|
||||
<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 />
|
||||
Note: If unchecked, button will only appear in the player controls after the set timeout.
|
||||
</div>
|
||||
@ -353,6 +435,7 @@
|
||||
Seconds to display skip prompt before introduction begins.
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div id="divHidePromptAdjustment" class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="HidePromptAdjustment">
|
||||
@ -363,12 +446,13 @@
|
||||
Seconds after introduction before skip prompt is hidden.
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="SecondsOfIntroToPlay">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RemainingSecondsOfIntro">
|
||||
Intro playback duration (in seconds)
|
||||
</label>
|
||||
<input id="SecondsOfIntroToPlay" type="number" is="emby-input" min="0" />
|
||||
<input id="RemainingSecondsOfIntro" type="number" is="emby-input" min="0" />
|
||||
<div class="fieldDescription">
|
||||
Seconds of introduction ending that should be played. Defaults to 2.
|
||||
</div>
|
||||
@ -407,6 +491,16 @@
|
||||
Message shown after automatically skipping an introduction. Leave blank to disable notification.
|
||||
</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>
|
||||
</fieldset>
|
||||
|
||||
@ -431,32 +525,54 @@
|
||||
<summary>Manage Fingerprints</summary>
|
||||
|
||||
<br />
|
||||
<h3 style="margin:0">Select episodes to manage</h3>
|
||||
<br />
|
||||
<select id="troubleshooterShow"></select>
|
||||
<select id="troubleshooterSeason"></select>
|
||||
<label class="inputLabel" for="troubleshooterShow">Select TV Series to manage</label>
|
||||
<select is="emby-select" id="troubleshooterShow" class="emby-select-withcolor emby-select"></select>
|
||||
<label class="inputLabel" for="troubleshooterSeason">Select Season to manage</label>
|
||||
<select is="emby-select" id="troubleshooterSeason" class="emby-select-withcolor emby-select"></select>
|
||||
<br />
|
||||
|
||||
<select id="troubleshooterEpisode1"></select>
|
||||
<select id="troubleshooterEpisode2"></select>
|
||||
<br />
|
||||
<label class="inputLabel" for="troubleshooterEpisode1">Select the first episode</label>
|
||||
<select is="emby-select" id="troubleshooterEpisode1" class="emby-select-withcolor emby-select"></select>
|
||||
<label class="inputLabel" for="troubleshooterEpisode1">Select the second episode</label>
|
||||
<select is="emby-select" id="troubleshooterEpisode2" class="emby-select-withcolor emby-select"></select>
|
||||
<br />
|
||||
|
||||
<div id="timestampEditor" style="display:none">
|
||||
<h3 style="margin:0">Introduction timestamp editor</h3>
|
||||
<p style="margin:0">All times are in seconds.</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">
|
||||
<p style="margin:0">All times are displayed in the format (HH:MM:SS)</p>
|
||||
<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 />
|
||||
|
||||
<button id="btnUpdateTimestamps" type="button">
|
||||
<button is="emby-select" id="btnUpdateTimestamps" class="raised button-submit block emby-button" type="button">
|
||||
Update timestamps
|
||||
</button>
|
||||
<br />
|
||||
@ -476,21 +592,23 @@
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<td style="min-width: 100px; font-weight: bold">Key</td>
|
||||
<td style="font-weight: bold">Function</td>
|
||||
<tr>
|
||||
<td style="min-width: 100px; font-weight: bold">Key</td>
|
||||
<td style="font-weight: bold">Function</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Up arrow</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.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Down arrow</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.
|
||||
</td>
|
||||
</tr>
|
||||
@ -568,28 +686,36 @@
|
||||
"AnalysisLengthLimit",
|
||||
"MinimumIntroDuration",
|
||||
"MaximumIntroDuration",
|
||||
"MinimumCreditsDuration",
|
||||
"MaximumCreditsDuration",
|
||||
"EdlAction",
|
||||
"ProcessPriority",
|
||||
"ProcessThreads",
|
||||
// playback
|
||||
"ShowPromptAdjustment",
|
||||
"HidePromptAdjustment",
|
||||
"SecondsOfIntroToPlay",
|
||||
"RemainingSecondsOfIntro",
|
||||
"SecondsOfIntroStartToPlay",
|
||||
"SecondsOfCreditsStartToPlay",
|
||||
// internals
|
||||
"SilenceDetectionMaximumNoise",
|
||||
"SilenceDetectionMinimumDuration",
|
||||
// UI customization
|
||||
"SkipButtonIntroText",
|
||||
"SkipButtonEndCreditsText",
|
||||
"AutoSkipNotificationText"
|
||||
"AutoSkipNotificationText",
|
||||
"AutoSkipCreditsNotificationText"
|
||||
]
|
||||
|
||||
var booleanConfigurationFields = [
|
||||
"AutoDetectIntros",
|
||||
"AutoDetectCredits",
|
||||
"AnalyzeSeasonZero",
|
||||
"RegenerateEdlFiles",
|
||||
"UseChromaprint",
|
||||
"CacheFingerprints",
|
||||
"AutoSkip",
|
||||
"AutoSkipCredits",
|
||||
"SkipFirstEpisode",
|
||||
"PersistSkipButton",
|
||||
"SkipButtonVisible"
|
||||
@ -613,20 +739,38 @@
|
||||
|
||||
var autoSkip = document.querySelector("input#AutoSkip");
|
||||
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 autoSkipCredits = document.querySelector("input#AutoSkipCredits");
|
||||
var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText");
|
||||
|
||||
async function autoSkipChanged() {
|
||||
if (autoSkip.checked) {
|
||||
skipFirstEpisode.style.display = 'unset';
|
||||
autoSkipNotificationText.style.display = 'unset';
|
||||
secondsOfIntroStartToPlay.style.display = 'unset';
|
||||
} else {
|
||||
skipFirstEpisode.style.display = 'none';
|
||||
autoSkipNotificationText.style.display = 'none';
|
||||
secondsOfIntroStartToPlay.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
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 showAdjustment = document.querySelector("div#divShowPromptAdjustment");
|
||||
var hideAdjustment = document.querySelector("div#divHidePromptAdjustment");
|
||||
@ -782,27 +926,24 @@
|
||||
// Get the title and ID of the left and right episodes
|
||||
const leftEpisode = selectEpisode1.options[selectEpisode1.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
|
||||
let leftEpisodeIntro = await getJson("Episode/" + leftEpisode.value + "/IntroTimestamps/v1");
|
||||
if (leftEpisodeIntro === null) {
|
||||
leftEpisodeIntro = { IntroStart: 0, IntroEnd: 0 };
|
||||
}
|
||||
|
||||
let rightEpisodeIntro = await getJson("Episode/" + rightEpisode.value + "/IntroTimestamps/v1");
|
||||
if (rightEpisodeIntro === null) {
|
||||
rightEpisodeIntro = { IntroStart: 0, IntroEnd: 0 };
|
||||
}
|
||||
const leftEpisodeJson = await getJson("Episode/" + leftEpisode.value + "/Timestamps");
|
||||
const rightEpisodeJson = await getJson("Episode/" + rightEpisode.value + "/Timestamps");
|
||||
|
||||
// Update the editor for the first and second episodes
|
||||
timestampEditor.style.display = "unset";
|
||||
document.querySelector("#editLeftEpisodeTitle").textContent = leftEpisode.text;
|
||||
document.querySelector("#editLeftEpisodeStart").value = Math.round(leftEpisodeIntro.IntroStart);
|
||||
document.querySelector("#editLeftEpisodeEnd").value = Math.round(leftEpisodeIntro.IntroEnd);
|
||||
document.querySelector("#editLeftIntroEpisodeStart").value = setTime(Math.round(leftEpisodeJson.Introduction.IntroStart));
|
||||
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("#editRightEpisodeStart").value = Math.round(rightEpisodeIntro.IntroStart);
|
||||
document.querySelector("#editRightEpisodeEnd").value = Math.round(rightEpisodeIntro.IntroEnd);
|
||||
document.querySelector("#editRightIntroEpisodeStart").value = setTime(Math.round(rightEpisodeJson.Introduction.IntroStart));
|
||||
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
|
||||
@ -867,13 +1008,13 @@
|
||||
case "ArrowDown":
|
||||
if (timestampError.value != "") {
|
||||
// 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;
|
||||
|
||||
case "ArrowUp":
|
||||
if (timestampError.value != "") {
|
||||
offsetDelta = e.ctrlKey ? -10 / 0.128 : -1;
|
||||
offsetDelta = e.ctrlKey ? -10 / 0.1238 : -1;
|
||||
}
|
||||
break;
|
||||
|
||||
@ -928,7 +1069,7 @@
|
||||
|
||||
// converts seconds to a readable timestamp (i.e. 127 becomes "02:07").
|
||||
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
|
||||
@ -966,6 +1107,7 @@
|
||||
}
|
||||
|
||||
autoSkipChanged();
|
||||
autoSkipCreditsChanged();
|
||||
persistSkipChanged();
|
||||
|
||||
Dashboard.hideLoadingMsg();
|
||||
@ -1030,19 +1172,30 @@
|
||||
});
|
||||
btnUpdateTimestamps.addEventListener("click", () => {
|
||||
const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value;
|
||||
const newLhsIntro = {
|
||||
IntroStart: document.querySelector("#editLeftEpisodeStart").value,
|
||||
IntroEnd: document.querySelector("#editLeftEpisodeEnd").value,
|
||||
const newLhs = {
|
||||
Introduction: {
|
||||
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 newRhsIntro = {
|
||||
IntroStart: document.querySelector("#editRightEpisodeStart").value,
|
||||
IntroEnd: document.querySelector("#editRightEpisodeEnd").value,
|
||||
const newRhs = {
|
||||
Introduction: {
|
||||
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("Intros/Episode/" + lhsId + "/UpdateIntroTimestamps", "POST", JSON.stringify(newLhsIntro));
|
||||
fetchWithAuth("Intros/Episode/" + rhsId + "/UpdateIntroTimestamps", "POST", JSON.stringify(newRhsIntro));
|
||||
fetchWithAuth("Episode/" + lhsId + "/Timestamps", "POST", JSON.stringify(newLhs));
|
||||
fetchWithAuth("Episode/" + rhsId + "/Timestamps", "POST", JSON.stringify(newRhs));
|
||||
|
||||
Dashboard.alert("New introduction timestamps saved");
|
||||
});
|
||||
@ -1056,12 +1209,12 @@
|
||||
|
||||
let lTime, rTime, diffPos;
|
||||
if (shift < 0) {
|
||||
lTime = y * 0.128;
|
||||
rTime = (y + shift) * 0.128;
|
||||
lTime = y * 0.1238;
|
||||
rTime = (y + shift) * 0.1238;
|
||||
diffPos = y + shift;
|
||||
} else {
|
||||
lTime = (y - shift) * 0.128;
|
||||
rTime = y * 0.128;
|
||||
lTime = (y - shift) * 0.1238;
|
||||
rTime = y * 0.1238;
|
||||
diffPos = y - shift;
|
||||
}
|
||||
|
||||
@ -1086,6 +1239,27 @@
|
||||
timeContainer.style.left = "25px";
|
||||
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>
|
||||
</div>
|
||||
</body>
|
||||
|
@ -6,105 +6,106 @@ let introSkipper = {
|
||||
};
|
||||
introSkipper.d = function (msg) {
|
||||
console.debug("[intro skipper] ", msg);
|
||||
}
|
||||
/** Setup event listeners */
|
||||
introSkipper.setup = function () {
|
||||
}
|
||||
/** Setup event listeners */
|
||||
introSkipper.setup = function () {
|
||||
document.addEventListener("viewshow", introSkipper.viewShow);
|
||||
window.fetch = introSkipper.fetchWrapper;
|
||||
introSkipper.d("Registered hooks");
|
||||
}
|
||||
/** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
|
||||
introSkipper.fetchWrapper = async function (...args) {
|
||||
}
|
||||
/** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
|
||||
introSkipper.fetchWrapper = async function (...args) {
|
||||
// Based on JellyScrub's trickplay.js
|
||||
let [resource, options] = args;
|
||||
let response = await introSkipper.originalFetch(resource, options);
|
||||
// Bail early if this isn't a playback info URL
|
||||
try {
|
||||
let path = new URL(resource).pathname;
|
||||
if (!path.includes("/PlaybackInfo")) { return response; }
|
||||
introSkipper.d("Retrieving skip segments from URL");
|
||||
introSkipper.d(path);
|
||||
let id = path.split("/")[2];
|
||||
introSkipper.skipSegments = await introSkipper.secureFetch(`Episode/${id}/IntroSkipperSegments`);
|
||||
introSkipper.d("Successfully retrieved skip segments");
|
||||
introSkipper.d(introSkipper.skipSegments);
|
||||
let path = new URL(resource).pathname;
|
||||
if (!path.includes("/PlaybackInfo")) { return response; }
|
||||
introSkipper.d("Retrieving skip segments from URL");
|
||||
introSkipper.d(path);
|
||||
|
||||
// Check for context root and set id accordingly
|
||||
let path_arr = path.split("/");
|
||||
let id = "";
|
||||
if (path_arr[1] == "Items") {
|
||||
id = path_arr[2];
|
||||
} else {
|
||||
id = path_arr[3];
|
||||
}
|
||||
|
||||
introSkipper.skipSegments = await introSkipper.secureFetch(`Episode/${id}/IntroSkipperSegments`);
|
||||
introSkipper.d("Successfully retrieved skip segments");
|
||||
introSkipper.d(introSkipper.skipSegments);
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Unable to get skip segments from", resource, e);
|
||||
console.error("Unable to get skip segments from", resource, e);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
/**
|
||||
* Event handler that runs whenever the current view changes.
|
||||
* Used to detect the start of video playback.
|
||||
*/
|
||||
introSkipper.viewShow = function () {
|
||||
}
|
||||
/**
|
||||
* Event handler that runs whenever the current view changes.
|
||||
* Used to detect the start of video playback.
|
||||
*/
|
||||
introSkipper.viewShow = function () {
|
||||
const location = window.location.hash;
|
||||
introSkipper.d("Location changed to " + location);
|
||||
if (location !== "#!/video") {
|
||||
introSkipper.d("Ignoring location change");
|
||||
return;
|
||||
introSkipper.d("Ignoring location change");
|
||||
return;
|
||||
}
|
||||
introSkipper.injectCss();
|
||||
introSkipper.injectButton();
|
||||
introSkipper.videoPlayer = document.querySelector("video");
|
||||
if (introSkipper.videoPlayer != null) {
|
||||
introSkipper.d("Hooking video timeupdate");
|
||||
introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged);
|
||||
introSkipper.d("Hooking video timeupdate");
|
||||
introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Injects the CSS used by the skip intro button.
|
||||
* Calling this function is a no-op if the CSS has already been injected.
|
||||
*/
|
||||
introSkipper.injectCss = function () {
|
||||
}
|
||||
/**
|
||||
* Injects the CSS used by the skip intro button.
|
||||
* Calling this function is a no-op if the CSS has already been injected.
|
||||
*/
|
||||
introSkipper.injectCss = function () {
|
||||
if (introSkipper.testElement("style#introSkipperCss")) {
|
||||
introSkipper.d("CSS already added");
|
||||
return;
|
||||
introSkipper.d("CSS already added");
|
||||
return;
|
||||
}
|
||||
introSkipper.d("Adding CSS");
|
||||
let styleElement = document.createElement("style");
|
||||
styleElement.id = "introSkipperCss";
|
||||
styleElement.innerText = `
|
||||
:root {
|
||||
--rounding: .2em;
|
||||
--accent: 0, 164, 220;
|
||||
}
|
||||
#skipIntro.upNextContainer {
|
||||
width: unset;
|
||||
}
|
||||
#skipIntro {
|
||||
padding: 0 1px;
|
||||
position: absolute;
|
||||
right: 10em;
|
||||
bottom: 9em;
|
||||
background-color: rgba(25, 25, 25, 0.66);
|
||||
border: 1px solid;
|
||||
border-radius: 0px;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 0 0 0 #f9f9f9;
|
||||
-webkit-transition: ease-out 0.4s;
|
||||
-moz-transition: ease-out 0.4s;
|
||||
transition: ease-out 0.4s;
|
||||
@media (max-width: 1080px) {
|
||||
right: 10%;
|
||||
}
|
||||
&:hover {
|
||||
box-shadow: inset 400px 0 0 0 #f9f9f9;
|
||||
-webkit-transition: ease-in 1s;
|
||||
-moz-transition: ease-in 1s;
|
||||
transition: ease-in 1s;
|
||||
}
|
||||
&.upNextContainer {
|
||||
width: unset;
|
||||
}
|
||||
@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;
|
||||
}
|
||||
bottom: 6em;
|
||||
right: 4.5em;
|
||||
background-color: transparent;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
#skipIntro .emby-button {
|
||||
text-shadow: 0 0 3px rgba(0, 0, 0, 0.7);
|
||||
border-radius: var(--rounding);
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
will-change: opacity, transform;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in, transform 0.3s ease-out;
|
||||
}
|
||||
#skipIntro .emby-button:hover,
|
||||
#skipIntro .emby-button:focus {
|
||||
background-color: rgba(var(--accent),0.7);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
#btnSkipSegmentText {
|
||||
padding-right: 0.15em;
|
||||
padding-left: 0.2em;
|
||||
margin-top: -0.1em;
|
||||
}
|
||||
`;
|
||||
document.querySelector("head").appendChild(styleElement);
|
||||
@ -135,7 +136,7 @@ introSkipper.injectButton = async function () {
|
||||
button.classList.add("hide");
|
||||
button.addEventListener("click", introSkipper.doSkip);
|
||||
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 class="material-icons skip_next"></span>
|
||||
</button>
|
||||
@ -174,21 +175,31 @@ introSkipper.videoPositionChanged = function () {
|
||||
if (!skipButton) {
|
||||
return;
|
||||
}
|
||||
const embyButton = skipButton.querySelector(".emby-button");
|
||||
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
|
||||
switch (segment["SegmentType"]) {
|
||||
switch (segment.SegmentType) {
|
||||
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;
|
||||
case "Introduction":
|
||||
skipButton.querySelector("#btnSkipSegmentText").textContent =
|
||||
skipButton.dataset["intro_text"];
|
||||
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.intro_text;
|
||||
break;
|
||||
case "Credits":
|
||||
skipButton.querySelector("#btnSkipSegmentText").textContent =
|
||||
skipButton.dataset["credits_text"];
|
||||
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.credits_text;
|
||||
break;
|
||||
}
|
||||
if (!skipButton.classList.contains("hide")) return;
|
||||
|
||||
skipButton.classList.remove("hide");
|
||||
embyButton.offsetWidth; // Force reflow
|
||||
requestAnimationFrame(() => {
|
||||
embyButton.style.opacity = '1';
|
||||
});
|
||||
}
|
||||
/** Seeks to the end of the intro. */
|
||||
introSkipper.doSkip = function (e) {
|
||||
|
@ -17,7 +17,7 @@ function findIntros() {
|
||||
// get the times of all similar fingerprint points
|
||||
for (let i in fprDiffs) {
|
||||
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.innerHTML = "";
|
||||
|
||||
const offset = Number(txtOffset.value) * 0.128;
|
||||
const offset = Number(txtOffset.value) * 0.1238;
|
||||
for (let r of ranges) {
|
||||
let lStart, lEnd, rStart, rEnd;
|
||||
|
||||
|
@ -2,8 +2,8 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace>
|
||||
<AssemblyVersion>0.1.16.3</AssemblyVersion>
|
||||
<FileVersion>0.1.16.3</FileVersion>
|
||||
<AssemblyVersion>0.10.8.1</AssemblyVersion>
|
||||
<FileVersion>0.10.8.1</FileVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Nullable>enable</Nullable>
|
||||
@ -24,4 +24,4 @@
|
||||
<EmbeddedResource Include="Configuration\inject.js" />
|
||||
<EmbeddedResource Include="Configuration\version.txt" />
|
||||
</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.Collections.Generic;
|
||||
using System.Net.Mime;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -47,6 +51,74 @@ public class SkipIntroController : ControllerBase
|
||||
return intro;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the timestamps for the provided episode.
|
||||
/// </summary>
|
||||
/// <param name="id">Episode ID to update timestamps for.</param>
|
||||
/// <param name="timestamps">New timestamps Introduction/Credits start and end times.</param>
|
||||
/// <response code="204">New timestamps saved.</response>
|
||||
/// <response code="404">Given ID is not an Episode.</response>
|
||||
/// <returns>No content.</returns>
|
||||
[Authorize(Policy = "RequiresElevation")]
|
||||
[HttpPost("Episode/{Id}/Timestamps")]
|
||||
public ActionResult UpdateTimestamps([FromRoute] Guid id, [FromBody] TimeStamps timestamps)
|
||||
{
|
||||
// only update existing episodes
|
||||
var rawItem = Plugin.Instance!.GetItem(id);
|
||||
if (rawItem == null || rawItem is not Episode episode)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (timestamps?.Introduction.IntroEnd > 0.0)
|
||||
{
|
||||
var tr = new TimeRange(timestamps.Introduction.IntroStart, timestamps.Introduction.IntroEnd);
|
||||
Plugin.Instance!.Intros[id] = new Intro(id, tr);
|
||||
}
|
||||
|
||||
if (timestamps?.Credits.IntroEnd > 0.0)
|
||||
{
|
||||
var cr = new TimeRange(timestamps.Credits.IntroStart, timestamps.Credits.IntroEnd);
|
||||
Plugin.Instance!.Credits[id] = new Intro(id, cr);
|
||||
}
|
||||
|
||||
Plugin.Instance!.SaveTimestamps();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamps for the provided episode.
|
||||
/// </summary>
|
||||
/// <param name="id">Episode ID.</param>
|
||||
/// <response code="200">Sucess.</response>
|
||||
/// <response code="404">Given ID is not an Episode.</response>
|
||||
/// <returns>Episode Timestamps.</returns>
|
||||
[HttpGet("Episode/{Id}/Timestamps")]
|
||||
[ActionName("UpdateTimestamps")]
|
||||
public ActionResult<TimeStamps> GetTimestamps([FromRoute] Guid id)
|
||||
{
|
||||
// only get return content for episodes
|
||||
var rawItem = Plugin.Instance!.GetItem(id);
|
||||
if (rawItem == null || rawItem is not Episode episode)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var times = new TimeStamps();
|
||||
if (Plugin.Instance!.Intros.TryGetValue(id, out var introValue))
|
||||
{
|
||||
times.Introduction = introValue;
|
||||
}
|
||||
|
||||
if (Plugin.Instance!.Credits.TryGetValue(id, out var creditValue))
|
||||
{
|
||||
times.Credits = creditValue;
|
||||
}
|
||||
|
||||
return times;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a dictionary of all skippable segments.
|
||||
/// </summary>
|
||||
@ -86,19 +158,19 @@ public class SkipIntroController : ControllerBase
|
||||
// Operate on a copy to avoid mutating the original Intro object stored in the dictionary.
|
||||
var segment = new Intro(timestamp);
|
||||
|
||||
var config = Plugin.Instance!.Configuration;
|
||||
segment.IntroEnd -= config.SecondsOfIntroToPlay;
|
||||
var config = Plugin.Instance.Configuration;
|
||||
segment.IntroEnd -= config.RemainingSecondsOfIntro;
|
||||
if (config.PersistSkipButton)
|
||||
{
|
||||
segment.ShowSkipPromptAt = segment.IntroStart;
|
||||
segment.HideSkipPromptAt = segment.IntroEnd;
|
||||
segment.ShowSkipPromptAt = segment.IntroStart;
|
||||
segment.HideSkipPromptAt = segment.IntroEnd - 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
|
||||
segment.HideSkipPromptAt = Math.Min(
|
||||
segment.IntroStart + config.HidePromptAdjustment,
|
||||
segment.IntroEnd);
|
||||
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
|
||||
segment.HideSkipPromptAt = Math.Min(
|
||||
segment.IntroStart + config.HidePromptAdjustment,
|
||||
segment.IntroEnd - 1);
|
||||
}
|
||||
|
||||
return segment;
|
||||
@ -153,8 +225,8 @@ public class SkipIntroController : ControllerBase
|
||||
foreach (var intro in timestamps)
|
||||
{
|
||||
// Get the details of the item from Jellyfin
|
||||
var rawItem = Plugin.Instance!.GetItem(intro.Key);
|
||||
if (rawItem is not Episode episode)
|
||||
var rawItem = Plugin.Instance.GetItem(intro.Key);
|
||||
if (rawItem == null || rawItem is not Episode episode)
|
||||
{
|
||||
throw new InvalidCastException("Unable to cast item id " + intro.Key + " to an Episode");
|
||||
}
|
||||
|
@ -1,3 +1,6 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
|
@ -1,7 +1,11 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net.Mime;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -140,6 +144,7 @@ public class VisualizationController : ControllerBase
|
||||
foreach (var e in episodes)
|
||||
{
|
||||
Plugin.Instance!.Intros.Remove(e.EpisodeId);
|
||||
Plugin.Instance!.Credits.Remove(e.EpisodeId);
|
||||
}
|
||||
|
||||
Plugin.Instance!.SaveTimestamps();
|
||||
@ -148,18 +153,22 @@ public class VisualizationController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the timestamps for the provided episode.
|
||||
/// Updates the introduction timestamps for the provided episode.
|
||||
/// </summary>
|
||||
/// <param name="id">Episode ID to update timestamps for.</param>
|
||||
/// <param name="timestamps">New introduction start and end times.</param>
|
||||
/// <response code="204">New introduction timestamps saved.</response>
|
||||
/// <returns>No content.</returns>
|
||||
[HttpPost("Episode/{Id}/UpdateIntroTimestamps")]
|
||||
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);
|
||||
Plugin.Instance!.Intros[id] = new Intro(id, tr);
|
||||
Plugin.Instance!.SaveTimestamps();
|
||||
if (timestamps.IntroEnd > 0.0)
|
||||
{
|
||||
var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd);
|
||||
Plugin.Instance!.Intros[id] = new Intro(id, tr);
|
||||
Plugin.Instance.SaveTimestamps();
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
@ -1,3 +1,6 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <summary>
|
||||
@ -34,4 +37,9 @@ public enum EdlAction
|
||||
/// Show a skip button.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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.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;
|
||||
|
||||
using System;
|
||||
|
@ -1,3 +1,6 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
|
||||
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.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;
|
||||
|
||||
using System;
|
||||
@ -63,14 +66,12 @@ public static class EdlManager
|
||||
{
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
else if (!intro.Valid)
|
||||
{
|
||||
_logger?.LogDebug("Episode {Id} did not have a valid introduction, skipping", id);
|
||||
_logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -84,7 +85,31 @@ public static class EdlManager
|
||||
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.IO;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
@ -14,9 +21,15 @@ public class Entrypoint : IServerEntryPoint
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IUserViewManager _userViewManager;
|
||||
private readonly ITaskManager _taskManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<Entrypoint> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private Timer _queueTimer;
|
||||
private bool _analyzeAgain;
|
||||
private static CancellationTokenSource? _cancellationTokenSource;
|
||||
private static ManualResetEventSlim _autoTaskCompletEvent = new ManualResetEventSlim(false);
|
||||
private QueueManager _queueManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Entrypoint"/> class.
|
||||
@ -24,20 +37,51 @@ public class Entrypoint : IServerEntryPoint
|
||||
/// <param name="userManager">User manager.</param>
|
||||
/// <param name="userViewManager">User view manager.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
/// <param name="taskManager">Task manager.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="loggerFactory">Logger factory.</param>
|
||||
public Entrypoint(
|
||||
IUserManager userManager,
|
||||
IUserViewManager userViewManager,
|
||||
ILibraryManager libraryManager,
|
||||
ITaskManager taskManager,
|
||||
ILogger<Entrypoint> logger,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_userViewManager = userViewManager;
|
||||
_libraryManager = libraryManager;
|
||||
_taskManager = taskManager;
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
|
||||
_queueTimer = new Timer(
|
||||
OnTimerCallback,
|
||||
null,
|
||||
Timeout.InfiniteTimeSpan,
|
||||
Timeout.InfiniteTimeSpan);
|
||||
|
||||
_queueManager = new QueueManager(
|
||||
_loggerFactory.CreateLogger<QueueManager>(),
|
||||
_libraryManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets State of the automatic task.
|
||||
/// </summary>
|
||||
public static TaskState AutomaticTaskState
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cancellationTokenSource is not null)
|
||||
{
|
||||
return _cancellationTokenSource.IsCancellationRequested
|
||||
? TaskState.Cancelling
|
||||
: TaskState.Running;
|
||||
}
|
||||
|
||||
return TaskState.Idle;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -46,17 +90,17 @@ public class Entrypoint : IServerEntryPoint
|
||||
/// <returns>Task.</returns>
|
||||
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
|
||||
// instead of waiting for the next task interval. The task start should be debounced by a few seconds.
|
||||
FFmpegWrapper.Logger = _logger;
|
||||
|
||||
try
|
||||
{
|
||||
// Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible
|
||||
_logger.LogInformation("Running startup enqueue");
|
||||
var queueManager = new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager);
|
||||
queueManager.GetMediaItems();
|
||||
_queueManager.GetMediaItems();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -66,6 +110,217 @@ public class Entrypoint : IServerEntryPoint
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Disclose source for inspiration
|
||||
// Implementation based on the principles of jellyfin-plugin-media-analyzer:
|
||||
// https://github.com/endrl/jellyfin-plugin-media-analyzer
|
||||
|
||||
/// <summary>
|
||||
/// Library item was added.
|
||||
/// </summary>
|
||||
/// <param name="sender">The sending entity.</param>
|
||||
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
|
||||
private void OnItemAdded(object? sender, ItemChangeEventArgs itemChangeEventArgs)
|
||||
{
|
||||
// Don't do anything if auto detection is disabled
|
||||
if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance!.Configuration.AutoDetectCredits)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't do anything if it's not a supported media type
|
||||
if (itemChangeEventArgs.Item is not Episode episode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Entrypoint.AutomaticTaskState == TaskState.Running)
|
||||
{
|
||||
_queueManager.QueueEpisode(episode);
|
||||
}
|
||||
else
|
||||
{
|
||||
Plugin.Instance!.Configuration.PathRestrictions.Add(itemChangeEventArgs.Item.ContainingFolderPath);
|
||||
StartTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Library item was modified.
|
||||
/// </summary>
|
||||
/// <param name="sender">The sending entity.</param>
|
||||
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
|
||||
private void OnItemModified(object? sender, ItemChangeEventArgs itemChangeEventArgs)
|
||||
{
|
||||
// Don't do anything if auto detection is disabled
|
||||
if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance!.Configuration.AutoDetectCredits)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't do anything if it's not a supported media type
|
||||
if (itemChangeEventArgs.Item is not Episode episode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Entrypoint.AutomaticTaskState == TaskState.Running)
|
||||
{
|
||||
_queueManager.QueueEpisode(episode);
|
||||
}
|
||||
else
|
||||
{
|
||||
Plugin.Instance!.Configuration.PathRestrictions.Add(itemChangeEventArgs.Item.ContainingFolderPath);
|
||||
StartTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TaskManager task ended.
|
||||
/// </summary>
|
||||
/// <param name="sender">The sending entity.</param>
|
||||
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
|
||||
private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs)
|
||||
{
|
||||
// Don't do anything if auto detection is disabled
|
||||
if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance!.Configuration.AutoDetectCredits)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var result = eventArgs.Result;
|
||||
|
||||
if (result.Key != "RefreshLibrary")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Status != TaskCompletionStatus.Completed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Unless user initiated, this is likely an overlap
|
||||
if (AutomaticTaskState == TaskState.Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StartTimer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start timer to debounce analyzing.
|
||||
/// </summary>
|
||||
private void StartTimer()
|
||||
{
|
||||
if (AutomaticTaskState == TaskState.Running)
|
||||
{
|
||||
_analyzeAgain = true; // Items added during a scan will be included later.
|
||||
}
|
||||
else if (ScheduledTaskSemaphore.CurrentCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Media Library changed, analyzis will start soon!");
|
||||
_queueTimer.Change(TimeSpan.FromMilliseconds(20000), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for timer callback to be completed.
|
||||
/// </summary>
|
||||
private void OnTimerCallback(object? state)
|
||||
{
|
||||
try
|
||||
{
|
||||
PerformAnalysis();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in PerformAnalysis");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for timer to be completed.
|
||||
/// </summary>
|
||||
private void PerformAnalysis()
|
||||
{
|
||||
_logger.LogInformation("Timer elapsed - start analyzing");
|
||||
_autoTaskCompletEvent.Reset();
|
||||
|
||||
using (_cancellationTokenSource = new CancellationTokenSource())
|
||||
{
|
||||
var progress = new Progress<double>();
|
||||
var cancellationToken = _cancellationTokenSource.Token;
|
||||
|
||||
var modes = new List<AnalysisMode>();
|
||||
var tasklogger = _loggerFactory.CreateLogger("DefaultLogger");
|
||||
|
||||
if (Plugin.Instance!.Configuration.AutoDetectIntros && Plugin.Instance!.Configuration.AutoDetectCredits)
|
||||
{
|
||||
modes.Add(AnalysisMode.Introduction);
|
||||
modes.Add(AnalysisMode.Credits);
|
||||
tasklogger = _loggerFactory.CreateLogger<DetectIntrosCreditsTask>();
|
||||
}
|
||||
else if (Plugin.Instance!.Configuration.AutoDetectIntros)
|
||||
{
|
||||
modes.Add(AnalysisMode.Introduction);
|
||||
tasklogger = _loggerFactory.CreateLogger<DetectIntrosTask>();
|
||||
}
|
||||
else if (Plugin.Instance!.Configuration.AutoDetectCredits)
|
||||
{
|
||||
modes.Add(AnalysisMode.Credits);
|
||||
tasklogger = _loggerFactory.CreateLogger<DetectCreditsTask>();
|
||||
}
|
||||
|
||||
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
|
||||
modes.AsReadOnly(),
|
||||
tasklogger,
|
||||
_loggerFactory,
|
||||
_libraryManager);
|
||||
|
||||
baseCreditAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||
}
|
||||
|
||||
Plugin.Instance!.Configuration.PathRestrictions.Clear();
|
||||
_autoTaskCompletEvent.Set();
|
||||
_cancellationTokenSource = null;
|
||||
|
||||
// New item detected, start timer again
|
||||
if (_analyzeAgain)
|
||||
{
|
||||
_logger.LogInformation("Analyzing ended, but we need to analyze again!");
|
||||
_analyzeAgain = false;
|
||||
StartTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Method to cancel the automatic task.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public static void CancelAutomaticTask(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cancellationTokenSource != null)
|
||||
{
|
||||
if (!_cancellationTokenSource.IsCancellationRequested)
|
||||
{
|
||||
_cancellationTokenSource.Cancel();
|
||||
}
|
||||
|
||||
_autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose.
|
||||
/// </summary>
|
||||
@ -83,6 +338,19 @@ public class Entrypoint : IServerEntryPoint
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,8 @@
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
@ -14,8 +18,6 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
/// </summary>
|
||||
public static class FFmpegWrapper
|
||||
{
|
||||
private static readonly object InvertedIndexCacheLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
|
||||
/// </summary>
|
||||
@ -34,7 +36,7 @@ public static class FFmpegWrapper
|
||||
|
||||
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>
|
||||
/// Check that the installed version of ffmpeg supports chromaprint.
|
||||
@ -137,15 +139,16 @@ public static class FFmpegWrapper
|
||||
/// </summary>
|
||||
/// <param name="id">Episode ID.</param>
|
||||
/// <param name="fingerprint">Chromaprint fingerprint.</param>
|
||||
/// <param name="mode">Mode.</param>
|
||||
/// <returns>Inverted index.</returns>
|
||||
public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint)
|
||||
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>();
|
||||
@ -159,10 +162,7 @@ public static class FFmpegWrapper
|
||||
invIndex[point] = i;
|
||||
}
|
||||
|
||||
lock (InvertedIndexCacheLock)
|
||||
{
|
||||
InvertedIndexCache[id] = invIndex;
|
||||
}
|
||||
innerDictionary[id] = invIndex;
|
||||
|
||||
return invIndex;
|
||||
}
|
||||
@ -261,24 +261,29 @@ public static class FFmpegWrapper
|
||||
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
|
||||
foreach (var line in raw.Split('\n'))
|
||||
{
|
||||
var matches = BlackFrameRegex.Matches(line);
|
||||
if (matches.Count != 2)
|
||||
// There is no FFmpeg flag to hide metadata such as description
|
||||
// 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) = (
|
||||
matches[0].Value.Split(':')[1],
|
||||
matches[1].Value.Split(':')[1]
|
||||
);
|
||||
var (strPercent, strTime) = (
|
||||
matches[0].Value.Split(':')[1],
|
||||
matches[1].Value.Split(':')[1]
|
||||
);
|
||||
|
||||
var bf = new BlackFrame(
|
||||
Convert.ToInt32(strPercent, CultureInfo.InvariantCulture),
|
||||
Convert.ToDouble(strTime, CultureInfo.InvariantCulture));
|
||||
var bf = new BlackFrame(
|
||||
Convert.ToInt32(strPercent, CultureInfo.InvariantCulture),
|
||||
Convert.ToDouble(strTime, CultureInfo.InvariantCulture));
|
||||
|
||||
if (bf.Percentage > minimum)
|
||||
{
|
||||
blackFrames.Add(bf);
|
||||
if (bf.Percentage > minimum)
|
||||
{
|
||||
blackFrames.Add(bf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -415,52 +420,46 @@ public static class FFmpegWrapper
|
||||
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(
|
||||
"Starting ffmpeg with the following arguments: {Arguments}",
|
||||
ffmpeg.StartInfo.Arguments);
|
||||
ffmpeg.Start();
|
||||
|
||||
ffmpeg.Start();
|
||||
|
||||
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
|
||||
try
|
||||
{
|
||||
var streamReader = stderr ? ffmpeg.StandardError : ffmpeg.StandardOutput;
|
||||
bytesRead = streamReader.BaseStream.Read(buf, 0, buf.Length);
|
||||
ms.Write(buf, 0, bytesRead);
|
||||
ffmpeg.PriorityClass = Plugin.Instance?.Configuration.ProcessPriority ?? ProcessPriorityClass.BelowNormal;
|
||||
}
|
||||
while (bytesRead > 0);
|
||||
|
||||
ffmpeg.WaitForExit(timeout);
|
||||
|
||||
var output = ms.ToArray();
|
||||
|
||||
// If caching is enabled, cache the output of this command.
|
||||
if (cacheOutput)
|
||||
catch (Exception e)
|
||||
{
|
||||
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
|
||||
// attributes that are applied to this project.
|
||||
// Project-level suppressions either have no target or are given
|
||||
// a specific target and scoped to a namespace, type, member, etc.
|
||||
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||
// SPDX-License-Identifier: GPL-3.0-only.
|
||||
|
||||
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.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using MediaBrowser.Model.Updates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
@ -61,15 +65,27 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
|
||||
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
|
||||
|
||||
var introsDirectory = Path.Join(applicationPaths.CachePath, "introskipper");
|
||||
FingerprintCachePath = Path.Join(introsDirectory, "chromaprints");
|
||||
_introPath = Path.Join(applicationPaths.CachePath, "introskipper", "intros.xml");
|
||||
_creditsPath = Path.Join(applicationPaths.CachePath, "introskipper", "credits.xml");
|
||||
var pluginDirName = "introskipper";
|
||||
var pluginCachePath = "chromaprints";
|
||||
|
||||
var oldintrosDirectory = Path.Join(applicationPaths.PluginConfigurationsPath, "intros");
|
||||
_oldFingerprintCachePath = Path.Join(oldintrosDirectory, "cache");
|
||||
_oldintroPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml");
|
||||
_oldcreditsPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "credits.xml");
|
||||
var introsDirectory = Path.Join(applicationPaths.DataPath, pluginDirName);
|
||||
FingerprintCachePath = Path.Join(introsDirectory, pluginCachePath);
|
||||
_introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml");
|
||||
_creditsPath = Path.Join(applicationPaths.DataPath, pluginDirName, "credits.xml");
|
||||
|
||||
var cacheRoot = applicationPaths.CachePath;
|
||||
var oldintrosDirectory = Path.Join(cacheRoot, pluginDirName);
|
||||
if (!Directory.Exists(oldintrosDirectory))
|
||||
{
|
||||
pluginDirName = "intros";
|
||||
pluginCachePath = "cache";
|
||||
cacheRoot = applicationPaths.PluginConfigurationsPath;
|
||||
oldintrosDirectory = Path.Join(cacheRoot, pluginDirName);
|
||||
}
|
||||
|
||||
_oldFingerprintCachePath = Path.Join(oldintrosDirectory, pluginCachePath);
|
||||
_oldintroPath = Path.Join(cacheRoot, pluginDirName, "intros.xml");
|
||||
_oldcreditsPath = Path.Join(cacheRoot, pluginDirName, "credits.xml");
|
||||
|
||||
// Create the base & cache directories (if needed).
|
||||
if (!Directory.Exists(FingerprintCachePath))
|
||||
@ -79,9 +95,19 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
// Check if the old cache directory exists
|
||||
if (Directory.Exists(_oldFingerprintCachePath))
|
||||
{
|
||||
// move intro.xml if exists
|
||||
if (File.Exists(_oldintroPath))
|
||||
{
|
||||
File.Move(_oldintroPath, _introPath);
|
||||
}
|
||||
|
||||
// move credits.xml if exits
|
||||
if (File.Exists(_oldcreditsPath))
|
||||
{
|
||||
File.Move(_oldcreditsPath, _creditsPath);
|
||||
}
|
||||
|
||||
// Move the contents from old directory to new directory
|
||||
File.Move(_oldintroPath, _introPath);
|
||||
File.Move(_oldcreditsPath, _creditsPath);
|
||||
string[] files = Directory.GetFiles(_oldFingerprintCachePath);
|
||||
foreach (string file in files)
|
||||
{
|
||||
@ -97,6 +123,8 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
|
||||
ConfigurationChanged += OnConfigurationChanged;
|
||||
|
||||
MigrateRepoUrl(serverConfiguration);
|
||||
|
||||
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
|
||||
try
|
||||
{
|
||||
@ -144,6 +172,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// </summary>
|
||||
public event EventHandler? AutoSkipChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Fired after configuration has been saved so the auto skip timer can be stopped or started.
|
||||
/// </summary>
|
||||
public event EventHandler? AutoSkipCreditsChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the results of fingerprinting all episodes.
|
||||
/// </summary>
|
||||
@ -200,7 +233,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
var introList = new List<Intro>();
|
||||
|
||||
// Serialize intros
|
||||
foreach (var intro in Plugin.Instance!.Intros)
|
||||
foreach (var intro in Instance!.Intros)
|
||||
{
|
||||
introList.Add(intro.Value);
|
||||
}
|
||||
@ -210,7 +243,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
// Serialize credits
|
||||
introList.Clear();
|
||||
|
||||
foreach (var intro in Plugin.Instance!.Credits)
|
||||
foreach (var intro in Instance!.Credits)
|
||||
{
|
||||
introList.Add(intro.Value);
|
||||
}
|
||||
@ -233,7 +266,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
|
||||
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)
|
||||
{
|
||||
Plugin.Instance!.Credits[credit.EpisodeId] = credit;
|
||||
Instance!.Credits[credit.EpisodeId] = credit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -302,7 +335,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
return commit;
|
||||
}
|
||||
|
||||
internal BaseItem GetItem(Guid id)
|
||||
internal BaseItem? GetItem(Guid id)
|
||||
{
|
||||
return _libraryManager.GetItemById(id);
|
||||
}
|
||||
@ -314,7 +347,15 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// <returns>Full path to item.</returns>
|
||||
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>
|
||||
@ -324,7 +365,15 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// <returns>List of chapters.</returns>
|
||||
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)
|
||||
@ -335,21 +384,69 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
if (mode == AnalysisMode.Introduction)
|
||||
{
|
||||
Plugin.Instance!.Intros[intro.Key] = intro.Value;
|
||||
Instance!.Intros[intro.Key] = intro.Value;
|
||||
}
|
||||
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)
|
||||
{
|
||||
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>
|
||||
@ -363,7 +460,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
|
||||
_logger.LogDebug("Reading index.html from {Path}", indexPath);
|
||||
var contents = File.ReadAllText(indexPath);
|
||||
_logger.LogDebug("Successfully read index.html");
|
||||
|
||||
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.
|
||||
// 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);
|
||||
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
|
||||
|
||||
// Write the modified file contents
|
||||
_logger.LogDebug("Saving modified file");
|
||||
File.WriteAllText(indexPath, contents);
|
||||
|
||||
_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;
|
||||
|
||||
using System;
|
||||
@ -57,15 +60,21 @@ public class QueueManager
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Running enqueue of items in library {Name}",
|
||||
folder.Name);
|
||||
_logger.LogInformation("Running enqueue of items in library {Name}", folder.Name);
|
||||
|
||||
try
|
||||
{
|
||||
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)
|
||||
@ -114,7 +123,7 @@ public class QueueManager
|
||||
if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Analysis settings have been changed to: {Percent}%/{Minutes}m and a minimum of {Minimum}s",
|
||||
"Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s",
|
||||
config.AnalysisPercent,
|
||||
config.AnalysisLengthLimit,
|
||||
config.MinimumIntroDuration);
|
||||
@ -140,8 +149,6 @@ public class QueueManager
|
||||
IsVirtualItem = false
|
||||
};
|
||||
|
||||
_logger.LogDebug("Getting items");
|
||||
|
||||
var items = _libraryManager.GetItemList(query, false);
|
||||
|
||||
if (items is null)
|
||||
@ -161,13 +168,25 @@ public class QueueManager
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Plugin.Instance!.Configuration.PathRestrictions.Count > 0)
|
||||
{
|
||||
if (!Plugin.Instance!.Configuration.PathRestrictions.Contains(item.ContainingFolderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
QueueEpisode(episode);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Queued {Count} episodes", items.Count);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@ -184,6 +203,19 @@ public class QueueManager
|
||||
return;
|
||||
}
|
||||
|
||||
// Allocate a new list for each new season
|
||||
_queuedEpisodes.TryAdd(episode.SeasonId, new List<QueuedEpisode>());
|
||||
|
||||
if (_queuedEpisodes[episode.SeasonId].Any(e => e.EpisodeId == episode.Id))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"\"{Name}\" from series \"{Series}\" ({Id}) is already queued",
|
||||
episode.Name,
|
||||
episode.SeriesName,
|
||||
episode.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit analysis to the first X% of the episode and at most Y minutes.
|
||||
// X and Y default to 25% and 10 minutes.
|
||||
var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
|
||||
@ -198,11 +230,8 @@ public class QueueManager
|
||||
fingerprintDuration,
|
||||
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
|
||||
var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumEpisodeCreditsDuration;
|
||||
var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumCreditsDuration;
|
||||
_queuedEpisodes[episode.SeasonId].Add(new QueuedEpisode()
|
||||
{
|
||||
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.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
public (ReadOnlyCollection<QueuedEpisode> VerifiedItems, bool AnyUnanalyzed)
|
||||
VerifyQueue(ReadOnlyCollection<QueuedEpisode> candidates, AnalysisMode mode)
|
||||
public (ReadOnlyCollection<QueuedEpisode> VerifiedItems, ReadOnlyCollection<AnalysisMode> RequiredModes)
|
||||
VerifyQueue(ReadOnlyCollection<QueuedEpisode> candidates, ReadOnlyCollection<AnalysisMode> modes)
|
||||
{
|
||||
var unanalyzed = false;
|
||||
var verified = new List<QueuedEpisode>();
|
||||
var reqModes = new List<AnalysisMode>();
|
||||
|
||||
var timestamps = mode == AnalysisMode.Introduction ?
|
||||
Plugin.Instance!.Intros :
|
||||
Plugin.Instance!.Credits;
|
||||
var requiresIntroAnalysis = modes.Contains(AnalysisMode.Introduction);
|
||||
var requiresCreditsAnalysis = modes.Contains(AnalysisMode.Credits);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
@ -244,24 +272,31 @@ public class QueueManager
|
||||
if (File.Exists(path))
|
||||
{
|
||||
verified.Add(candidate);
|
||||
}
|
||||
|
||||
if (!timestamps.ContainsKey(candidate.EpisodeId))
|
||||
{
|
||||
unanalyzed = true;
|
||||
if (requiresIntroAnalysis && (!Plugin.Instance!.Intros.TryGetValue(candidate.EpisodeId, out var intro) || !intro.Valid))
|
||||
{
|
||||
reqModes.Add(AnalysisMode.Introduction);
|
||||
requiresIntroAnalysis = false; // No need to check again
|
||||
}
|
||||
|
||||
if (requiresCreditsAnalysis && (!Plugin.Instance!.Credits.TryGetValue(candidate.EpisodeId, out var credit) || !credit.Valid))
|
||||
{
|
||||
reqModes.Add(AnalysisMode.Credits);
|
||||
requiresCreditsAnalysis = false; // No need to check again
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping {Mode} analysis of {Name} ({Id}): {Exception}",
|
||||
mode,
|
||||
modes,
|
||||
candidate.Name,
|
||||
candidate.EpisodeId,
|
||||
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;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@ -12,7 +16,7 @@ using Microsoft.Extensions.Logging;
|
||||
/// </summary>
|
||||
public class BaseItemAnalyzerTask
|
||||
{
|
||||
private readonly AnalysisMode _analysisMode;
|
||||
private readonly ReadOnlyCollection<AnalysisMode> _analysisModes;
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
@ -23,22 +27,22 @@ public class BaseItemAnalyzerTask
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BaseItemAnalyzerTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="mode">Analysis mode.</param>
|
||||
/// <param name="modes">Analysis mode.</param>
|
||||
/// <param name="logger">Task logger.</param>
|
||||
/// <param name="loggerFactory">Logger factory.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
public BaseItemAnalyzerTask(
|
||||
AnalysisMode mode,
|
||||
ReadOnlyCollection<AnalysisMode> modes,
|
||||
ILogger logger,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_analysisMode = mode;
|
||||
_analysisModes = modes;
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
_libraryManager = libraryManager;
|
||||
|
||||
if (mode == AnalysisMode.Introduction)
|
||||
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
|
||||
{
|
||||
EdlManager.Initialize(_logger);
|
||||
}
|
||||
@ -73,18 +77,21 @@ public class BaseItemAnalyzerTask
|
||||
totalQueued += kvp.Value.Count;
|
||||
}
|
||||
|
||||
totalQueued *= _analysisModes.Count;
|
||||
|
||||
if (totalQueued == 0)
|
||||
{
|
||||
throw new FingerprintException(
|
||||
"No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly.");
|
||||
}
|
||||
|
||||
if (this._analysisMode == AnalysisMode.Introduction)
|
||||
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
|
||||
{
|
||||
EdlManager.LogConfiguration();
|
||||
}
|
||||
|
||||
var totalProcessed = 0;
|
||||
var modeCount = _analysisModes.Count;
|
||||
var options = new ParallelOptions()
|
||||
{
|
||||
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
|
||||
@ -94,29 +101,55 @@ public class BaseItemAnalyzerTask
|
||||
{
|
||||
var writeEdl = false;
|
||||
|
||||
var totalRemaining = (Plugin.Instance!.TotalQueued * modeCount) - totalProcessed;
|
||||
|
||||
if (totalRemaining >= queue.Count * modeCount)
|
||||
{
|
||||
queue = new(Plugin.Instance!.QueuedMediaItems);
|
||||
totalQueued = 0;
|
||||
foreach (var kvp in queue)
|
||||
{
|
||||
totalQueued += kvp.Value.Count;
|
||||
}
|
||||
|
||||
totalQueued *= _analysisModes.Count;
|
||||
}
|
||||
|
||||
// Since the first run of the task can run for multiple hours, ensure that none
|
||||
// of the current media items were deleted from Jellyfin since the task was started.
|
||||
var (episodes, unanalyzed) = queueManager.VerifyQueue(
|
||||
var (episodes, requiredModes) = queueManager.VerifyQueue(
|
||||
season.Value.AsReadOnly(),
|
||||
this._analysisMode);
|
||||
_analysisModes);
|
||||
|
||||
if (episodes.Count == 0)
|
||||
var episodeCount = episodes.Count;
|
||||
|
||||
if (episodeCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var first = episodes[0];
|
||||
var requiredModeCount = requiredModes.Count;
|
||||
|
||||
if (!unanalyzed)
|
||||
if (requiredModeCount == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"All episodes in {Name} season {Season} have already been analyzed",
|
||||
first.SeriesName,
|
||||
first.SeasonNumber);
|
||||
|
||||
Interlocked.Add(ref totalProcessed, episodeCount * modeCount); // Update total Processed directly
|
||||
progress.Report((totalProcessed * 100) / totalQueued);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (modeCount != requiredModeCount)
|
||||
{
|
||||
Interlocked.Add(ref totalProcessed, episodeCount);
|
||||
progress.Report((totalProcessed * 100) / totalQueued); // Partial analysis some modes have already been analyzed
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
@ -124,10 +157,15 @@ public class BaseItemAnalyzerTask
|
||||
return;
|
||||
}
|
||||
|
||||
var analyzed = AnalyzeItems(episodes, cancellationToken);
|
||||
Interlocked.Add(ref totalProcessed, analyzed);
|
||||
foreach (AnalysisMode mode in requiredModes)
|
||||
{
|
||||
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)
|
||||
{
|
||||
@ -138,20 +176,13 @@ public class BaseItemAnalyzerTask
|
||||
ex);
|
||||
}
|
||||
|
||||
if (
|
||||
writeEdl &&
|
||||
Plugin.Instance!.Configuration.EdlAction != EdlAction.None &&
|
||||
_analysisMode == AnalysisMode.Introduction)
|
||||
if (writeEdl && Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
|
||||
{
|
||||
EdlManager.UpdateEDLFiles(episodes);
|
||||
}
|
||||
|
||||
progress.Report((totalProcessed * 100) / totalQueued);
|
||||
});
|
||||
|
||||
if (
|
||||
_analysisMode == AnalysisMode.Introduction &&
|
||||
Plugin.Instance!.Configuration.RegenerateEdlFiles)
|
||||
if (Plugin.Instance!.Configuration.RegenerateEdlFiles)
|
||||
{
|
||||
_logger.LogInformation("Turning EDL file regeneration flag off");
|
||||
Plugin.Instance!.Configuration.RegenerateEdlFiles = false;
|
||||
@ -163,10 +194,12 @@ public class BaseItemAnalyzerTask
|
||||
/// Analyze a group of media items for skippable segments.
|
||||
/// </summary>
|
||||
/// <param name="items">Media items to analyze.</param>
|
||||
/// <param name="mode">Analysis mode.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of items that were successfully analyzed.</returns>
|
||||
private int AnalyzeItems(
|
||||
ReadOnlyCollection<QueuedEpisode> items,
|
||||
AnalysisMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var totalItems = items.Count;
|
||||
@ -179,7 +212,8 @@ public class BaseItemAnalyzerTask
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Analyzing {Count} files from {Name} season {Season}",
|
||||
"[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}",
|
||||
mode,
|
||||
items.Count,
|
||||
first.SeriesName,
|
||||
first.SeasonNumber);
|
||||
@ -187,21 +221,21 @@ public class BaseItemAnalyzerTask
|
||||
var analyzers = new Collection<IMediaFileAnalyzer>();
|
||||
|
||||
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
|
||||
if (mode == AnalysisMode.Credits)
|
||||
{
|
||||
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
|
||||
}
|
||||
|
||||
if (Plugin.Instance!.Configuration.UseChromaprint)
|
||||
{
|
||||
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
|
||||
}
|
||||
|
||||
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
|
||||
// analyzed items from the queue.
|
||||
foreach (var analyzer in analyzers)
|
||||
{
|
||||
items = analyzer.AnalyzeMediaFiles(items, this._analysisMode, cancellationToken);
|
||||
items = analyzer.AnalyzeMediaFiles(items, mode, cancellationToken);
|
||||
}
|
||||
|
||||
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.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@ -14,6 +18,8 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
/// </summary>
|
||||
public class DetectCreditsTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<DetectCreditsTask> _logger;
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
@ -23,10 +29,13 @@ public class DetectCreditsTask : IScheduledTask
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public DetectCreditsTask(
|
||||
ILogger<DetectCreditsTask> logger,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
@ -44,7 +53,7 @@ public class DetectCreditsTask : IScheduledTask
|
||||
/// <summary>
|
||||
/// Gets the task description.
|
||||
/// </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>
|
||||
/// Gets the task key.
|
||||
@ -64,14 +73,35 @@ public class DetectCreditsTask : IScheduledTask
|
||||
throw new InvalidOperationException("Library manager was null");
|
||||
}
|
||||
|
||||
var baseAnalyzer = new BaseItemAnalyzerTask(
|
||||
AnalysisMode.Credits,
|
||||
// abort automatic analyzer if running
|
||||
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
|
||||
{
|
||||
_logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
|
||||
Entrypoint.CancelAutomaticTask(cancellationToken);
|
||||
}
|
||||
|
||||
ScheduledTaskSemaphore.Wait(-1, cancellationToken);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
ScheduledTaskSemaphore.Release();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Scheduled Task is starting");
|
||||
|
||||
Plugin.Instance!.Configuration.PathRestrictions.Clear();
|
||||
var modes = new List<AnalysisMode> { AnalysisMode.Credits };
|
||||
|
||||
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
|
||||
modes.AsReadOnly(),
|
||||
_loggerFactory.CreateLogger<DetectCreditsTask>(),
|
||||
_loggerFactory,
|
||||
_libraryManager);
|
||||
|
||||
baseAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||
baseCreditAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||
|
||||
ScheduledTaskSemaphore.Release();
|
||||
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.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@ -11,21 +15,26 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||
/// <summary>
|
||||
/// Analyze all television episodes for introduction sequences.
|
||||
/// </summary>
|
||||
public class DetectIntroductionsTask : IScheduledTask
|
||||
public class DetectIntrosCreditsTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<DetectIntrosCreditsTask> _logger;
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DetectIntroductionsTask"/> class.
|
||||
/// Initializes a new instance of the <see cref="DetectIntrosCreditsTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
public DetectIntroductionsTask(
|
||||
/// <param name="logger">Logger.</param>
|
||||
public DetectIntrosCreditsTask(
|
||||
ILogger<DetectIntrosCreditsTask> logger,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
@ -33,7 +42,7 @@ public class DetectIntroductionsTask : IScheduledTask
|
||||
/// <summary>
|
||||
/// Gets the task name.
|
||||
/// </summary>
|
||||
public string Name => "Detect Introductions";
|
||||
public string Name => "Detect Intros and Credits";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task category.
|
||||
@ -43,12 +52,12 @@ public class DetectIntroductionsTask : IScheduledTask
|
||||
/// <summary>
|
||||
/// Gets the task description.
|
||||
/// </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>
|
||||
/// Gets the task key.
|
||||
/// </summary>
|
||||
public string Key => "CPBIntroSkipperDetectIntroductions";
|
||||
public string Key => "CPBIntroSkipperDetectIntrosCredits";
|
||||
|
||||
/// <summary>
|
||||
/// 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");
|
||||
}
|
||||
|
||||
var baseAnalyzer = new BaseItemAnalyzerTask(
|
||||
AnalysisMode.Introduction,
|
||||
_loggerFactory.CreateLogger<DetectIntroductionsTask>(),
|
||||
// abort automatic analyzer if running
|
||||
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
|
||||
{
|
||||
_logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
|
||||
Entrypoint.CancelAutomaticTask(cancellationToken);
|
||||
}
|
||||
|
||||
ScheduledTaskSemaphore.Wait(-1, cancellationToken);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
ScheduledTaskSemaphore.Release();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Scheduled Task is starting");
|
||||
|
||||
Plugin.Instance!.Configuration.PathRestrictions.Clear();
|
||||
var modes = new List<AnalysisMode> { AnalysisMode.Introduction, AnalysisMode.Credits };
|
||||
|
||||
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
|
||||
modes.AsReadOnly(),
|
||||
_loggerFactory.CreateLogger<DetectIntrosCreditsTask>(),
|
||||
_loggerFactory,
|
||||
_libraryManager);
|
||||
|
||||
baseAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||
baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
|
||||
|
||||
ScheduledTaskSemaphore.Release();
|
||||
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)
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
<img alt="Plugin Banner" src="https://raw.githubusercontent.com/jumoog/intro-skipper/master/images/logo.png" />
|
||||
@ -9,6 +8,12 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## Manifest URL (All Jellyfin Versions)
|
||||
|
||||
```
|
||||
https://manifest.intro-skipper.org/manifest.json
|
||||
```
|
||||
|
||||
## System requirements
|
||||
|
||||
* Jellyfin 10.8.4 (or newer)
|
||||
@ -16,73 +21,25 @@
|
||||
* `jellyfin/jellyfin` 10.8.z container: preinstalled
|
||||
* `linuxserver/jellyfin` 10.8.z container: preinstalled
|
||||
* 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
|
||||
* Between 15 seconds and 2 minutes long
|
||||
## [Detection types](https://github.com/intro-skipper/intro-skipper/wiki#detection-types)
|
||||
|
||||
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
|
||||
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
|
||||
## [API Documentation](https://github.com/intro-skipper/intro-skipper/blob/master/docs/api.md)
|
||||
|
||||
## Troubleshooting
|
||||
#### Scheduled tasks fail instantly
|
||||
- Verify that Intro Skipper can detect ffmpeg with Chromaprint
|
||||
- Dashboard -> Plugins -> Intro Skipper -> Support Bundle Info
|
||||
- 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).
|
||||
<br />
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/AYZ7RJ3BuA"><img src="https://invidget.switchblade.xyz/AYZ7RJ3BuA"></a>
|
||||
</p>
|
||||
|
@ -4,73 +4,17 @@
|
||||
"name": "Intro Skipper",
|
||||
"overview": "Automatically detect and skip intros in television episodes",
|
||||
"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",
|
||||
"imageUrl": "https://raw.githubusercontent.com/jumoog/intro-skipper/master/images/logo.png",
|
||||
"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",
|
||||
"targetAbi": "10.8.4.0",
|
||||
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.16/intro-skipper-v0.1.16.zip",
|
||||
"checksum": "8989cbe9c438d5a14fab3002e21c26ba",
|
||||
"timestamp": "2024-03-04T10:10:35Z"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"sourceUrl": "https://github.com/intro-skipper/intro-skipper/releases/download/10.8/v0.10.8.1/intro-skipper-v0.10.8.1.zip",
|
||||
"checksum": "76cdb2bad9582d23c1f6f4d868218d6c",
|
||||
"timestamp": "2024-10-26T18:10:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
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