Compare commits

...

57 Commits
10.10 ... 10.9

Author SHA1 Message Date
TwistedUmbrellaX
350c7684e3
Titles only for old versions 2024-11-26 13:14:39 -05:00
TwistedUmbrellaX
6f504132ac
Change a wiki heading 2024-11-26 13:10:18 -05:00
github-actions[bot]
d3fb5ff8d7 release v1.10.9.2 2024-11-05 14:36:23 +00:00
Kilian von Pflugk
df28ef8be7 ci: don't build on PRs (#370) 2024-11-05 14:29:21 +01:00
TwistedUmbrellaX
25d6700c1a List the right ffmpeg version in log 2024-11-05 06:18:32 -05:00
TwistedUmbrellaX
71c2c69605 Link to the wiki sections directly 2024-11-03 05:12:06 -05:00
TwistedUmbrellaX
390ff41ac3 Move UI items into UI section 2024-10-31 12:05:23 -04:00
Kilian von Pflugk
d9973ed90a check that old config is not null 2024-10-30 20:37:56 +01:00
Kilian von Pflugk
36825a898d fix namespace 2024-10-30 20:37:49 +01:00
TwistedUmbrellaX
74fd8f3f75 Update configPage.html 2024-10-30 09:55:28 -04:00
TwistedUmbrellaX
e1420027c0 Update PluginConfiguration.cs 2024-10-30 09:55:28 -04:00
Kilian von Pflugk
89f996100c Revert "Temporary workaround"
This reverts commit 61739a1afd6f2db4a87c12c44e69a4c6a039b2e5.
2024-10-29 21:23:27 +01:00
TwistedUmbrellaX
61739a1afd Temporary workaround 2024-10-29 14:52:44 -04:00
TwistedUmbrellaX
3e92d0f6f5 Rename a duplicate label 2024-10-29 10:19:19 -04:00
TwistedUmbrellaX
01a6d855f9 Stop introducing this typo
It keeps popping up randomly
2024-10-29 07:42:07 -04:00
TwistedUmbrellaX
6bb92eda01 fix a typo 2024-10-28 20:50:39 -04:00
TwistedUmbrellaX
f9611eae78 Some additional formatting 2024-10-28 20:48:54 -04:00
TwistedUmbrellaX
8f46af69e4 More standardized formatting 2024-10-28 20:34:31 -04:00
TwistedUmbrellaX
b4fdf14f26 Reorganize settings by relation 2024-10-28 20:33:12 -04:00
TwistedUmbrellaX
9893aac067 Fix a typo in the config page editor 2024-10-27 21:41:29 -04:00
TwistedUmbrellaX
e68eaf8c0e Update ignore for alternate IDE 2024-10-27 21:15:25 -04:00
Kilian von Pflugk
f32408109d allow users to override the URL (#360) 2024-10-27 22:43:15 +01:00
TwistedUmbrellaX
97840f2a7d Swap editor and support dropdown 2024-10-27 12:51:11 -04:00
TwistedUmbrellaX
9ff8b19ab5 Warn without disabling toggle 2024-10-27 04:44:54 -04:00
TwistedUmbrellaX
2653c0a314
Restart versioning for manifest 2024-10-26 14:13:06 -04:00
github-actions[bot]
80bbc0fa00 release v1.10.9.1 2024-10-26 18:09:56 +00:00
TwistedUmbrellaX
e9582d431c
Realign versioning with jellyfin 2024-10-26 14:03:11 -04:00
TwistedUmbrellaX
40474b2d3b And Chrome is somehow worse 2024-10-25 22:12:43 -04:00
TwistedUmbrellaX
073245a890 JMP breaks inline strong tags 2024-10-25 20:13:03 -04:00
Kilian von Pflugk
bd2b6d4bca ci: remove cloudflare deploy 2024-10-25 21:37:54 +02:00
TwistedUmbrellaX
45279e0a85 Fix identifier and name 2024-10-25 14:33:26 -04:00
TwistedUmbrellaX
7de97ae7da Implement SPDX GLPv3.0 LICENSE 2024-10-25 13:42:34 -04:00
TwistedUmbrellaX
a9baea5c16 Update LICENSE for Intro-Skipper 2024-10-25 12:23:15 -04:00
TwistedUmbrellaX
06f69e6602 Update README.md 2024-10-24 22:29:52 -04:00
Kilian von Pflugk
45d9e4b632 switch to our new domain 2024-10-24 22:48:12 +02:00
rlauu
4d7dbf7f0f check if valid 2024-10-23 10:33:28 -04:00
rlauu
fe57d4defa Skip Applying RemainingSecondsOfIntro for Segments at the End of the Video 2024-10-23 15:19:18 +02:00
rlauuzo
3445bbaee4 typo 2024-10-23 13:43:31 +02:00
TwistedUmbrellaX
38bc136088 Formatting for error messages 2024-10-22 20:46:33 -04:00
TwistedUmbrellaX
50ac67113a Make the ignore button label specific 2024-10-22 20:46:33 -04:00
rlauuzo
627ae05def analyze movies (#348)
* scan movies

* Update ConfusedPolarBear.Plugin.IntroSkipper.csproj

* fix

* Update SegmentProvider.cs

* fix

* update

* add movies to endpoints

* Update

* Update QueueManager.cs

* revert

* Update configPage.html

Battery died. I’ll be back

* “Borrow” show config to hide seasons

* Add IsMovie to ShowInfos

* remove unused usings

* Add option to enable/disble movies

* Use the left episode as movie editor

* Timestamp erasure for movies

* Add max credits duration for movies

* Formatting and button style cleanup

* remove fingerprint timings for movies

* remove x2 from MaximumCreditsDuration in blackframe analyzer

* Update SegmentProvider.cs

* Update SegmentProvider.cs

* Update SegmentProvider.cs

* Update SegmentProvider.cs

* Update BaseItemAnalyzerTask.cs

---------

Co-Authored-By: rlauu <46294892+rlauu@users.noreply.github.com>
Co-Authored-By: TwistedUmbrellaX <1173913+AbandonedCart@users.noreply.github.com>
2024-10-22 20:46:33 -04:00
Kilian von Pflugk
7198dfdfca add recommend vs code settings 2024-10-21 21:01:22 +02:00
Kilian von Pflugk
acb902824d migrate old plugin config 2024-10-20 14:02:40 +02:00
Kilian von Pflugk
fef3b4d178 rename ConfusedPolarBear.Plugin.IntroSkipper -> IntroSkipper 2024-10-20 14:02:32 +02:00
Kilian von Pflugk
8627203748 ci: make github actions reusable 2024-10-18 12:54:28 +02:00
Kilian von Pflugk
8d0f17e18b
remove old release 2024-10-17 18:18:12 +02:00
Kilian von Pflugk
6387d0e6a2 ci: use correct branch for tag creating 2024-10-17 17:55:50 +02:00
github-actions[bot]
bdc7c9914c release v1.0.0.7 2024-10-17 15:28:32 +00:00
TwistedUmbrellaX
e1ad284fab Formatting and button style cleanup 2024-10-16 22:04:09 -04:00
Kilian von Pflugk
e59bc24965 ci: send the new manifest to cloudflare KV 2024-10-13 21:38:14 +02:00
Kilian von Pflugk
12d82da4fa
ci: add jellyfin version 2024-10-13 19:21:25 +00:00
github-actions[bot]
7327f3f46e release v1.0.0.6 2024-10-12 14:25:03 +00:00
Kilian von Pflugk
84801c8634
migrate own repo url (#345) 2024-10-12 16:13:37 +02:00
Kilian von Pflugk
2b87b122c2
manifest.json 2024-10-12 14:39:17 +02:00
Kilian von Pflugk
4cdef4f228
switch to new manifest url 2024-10-12 14:18:01 +02:00
rlauu
7264b7b8f0 Remove the EDL option for the skip button since it's not working 2024-10-12 12:30:25 +02:00
Kilian von Pflugk
d1bdf764d1 10.9 2024-10-11 19:00:23 +02:00
101 changed files with 1514 additions and 962 deletions

View File

@ -192,3 +192,5 @@ csharp_space_between_method_call_empty_parameter_list_parentheses = false
# Wrapping preferences # Wrapping preferences
csharp_preserve_single_line_statements = true csharp_preserve_single_line_statements = true
csharp_preserve_single_line_blocks = true csharp_preserve_single_line_blocks = true
file_header_template = Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>\nSPDX-License-Identifier: GPL-3.0-only.

View File

@ -2,15 +2,8 @@ name: "Build Plugin"
on: on:
push: push:
branches: ["master"] branches:
paths-ignore: - '*' # Triggers on any branch push
- "**/README.md"
- ".github/ISSUE_TEMPLATE/**"
- "docs/**"
- "images/**"
- "manifest.json"
pull_request:
branches: ["master"]
paths-ignore: paths-ignore:
- "**/README.md" - "**/README.md"
- ".github/ISSUE_TEMPLATE/**" - ".github/ISSUE_TEMPLATE/**"
@ -39,6 +32,22 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Read version from VERSION.txt
id: read-version
run: |
MAIN_VERSION=$(cat VERSION.txt)
echo "MAIN_VERSION=${MAIN_VERSION}"
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV
- name: Check for BETA file
id: check-beta
run: |
if [ -f "BETA" ]; then
echo "IS_BETA=true" >> $GITHUB_ENV
else
echo "IS_BETA=false" >> $GITHUB_ENV
fi
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
@ -51,17 +60,25 @@ jobs:
- name: Minify HTML - name: Minify HTML
run: | 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 html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o IntroSkipper/Configuration/configPage.html IntroSkipper/Configuration/configPage.html
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m npx terser IntroSkipper/Configuration/inject.js -o IntroSkipper/Configuration/inject.js -c -m
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -c -m npx terser IntroSkipper/Configuration/visualizer.js -o IntroSkipper/Configuration/visualizer.js -c -m
- name: Restore Beta dependencies
if: ${{env.IS_BETA == 'true' }}
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
dotnet tool install --global dotnet-outdated-tool
dotnet outdated -pre Always -u -inc Jellyfin
- name: Restore dependencies - name: Restore dependencies
if: ${{env.IS_BETA == 'false' }}
run: dotnet restore run: dotnet restore
- name: Embed version info - name: Embed version info
run: | run: |
GITHUB_SHA=${{ github.sha }} GITHUB_SHA=${{ github.sha }}
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" ConfusedPolarBear.Plugin.IntroSkipper/Helper/Commit.cs sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" IntroSkipper/Helper/Commit.cs
- name: Retrieve commit identification - name: Retrieve commit identification
run: | run: |
@ -73,29 +90,19 @@ jobs:
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4.3.6 uses: actions/upload-artifact@v4.3.6
if: github.event_name != 'pull_request'
with: with:
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.GIT_HASH }}.dll name: IntroSkipper-${{ env.GIT_HASH }}.dll
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll path: IntroSkipper/bin/Debug/net8.0/IntroSkipper.dll
if-no-files-found: error
- name: Upload artifact
uses: actions/upload-artifact@v4.3.6
if: github.event_name == 'pull_request'
with:
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.SANITIZED_BRANCH_NAME }}.dll
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
retention-days: 7
if-no-files-found: error if-no-files-found: error
- name: Create archive - name: Create archive
if: github.event_name != 'pull_request' if: startsWith(github.ref_name, '10.') # only do a preview release on 10.x branches
run: zip -j "intro-skipper-${{ env.GIT_HASH }}.zip" ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll run: zip -j "intro-skipper-${{ env.GIT_HASH }}.zip" IntroSkipper/bin/Debug/net8.0/IntroSkipper.dll
- name: Create/replace the preview release and upload artifacts - name: Create/replace the preview release and upload artifacts
if: github.event_name != 'pull_request' if: startsWith(github.ref_name, '10.') # only do a preview release on 10.x branches
run: | run: |
gh release delete '10.9/preview' --cleanup-tag --yes || true gh release delete "${{ env.MAIN_VERSION }}/preview" --cleanup-tag --yes || true
gh release create '10.9/preview' "intro-skipper-${{ env.GIT_HASH }}.zip" --prerelease --title "intro-skipper-${{ env.GIT_HASH }}" --notes "This is a prerelease version." gh release create "${{ env.MAIN_VERSION }}/preview" "intro-skipper-${{ env.GIT_HASH }}.zip" --prerelease --title "intro-skipper-${{ env.GIT_HASH }}" --notes "This is a prerelease version." --target ${{ env.MAIN_VERSION }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -2,15 +2,8 @@ name: "CodeQL"
on: on:
push: push:
branches: [master] branches:
paths-ignore: - '*' # Triggers on any branch push
- "**/README.md"
- ".github/ISSUE_TEMPLATE/**"
- "docs/**"
- "images/**"
- "manifest.json"
pull_request:
branches: [master]
paths-ignore: paths-ignore:
- "**/README.md" - "**/README.md"
- ".github/ISSUE_TEMPLATE/**" - ".github/ISSUE_TEMPLATE/**"
@ -36,17 +29,31 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Check for BETA file
id: check-beta
run: |
if [ -f "BETA" ]; then
echo "IS_BETA=true" >> $GITHUB_ENV
else
echo "IS_BETA=false" >> $GITHUB_ENV
fi
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
- name: Install dependencies - name: Restore Beta dependencies
if: ${{env.IS_BETA == 'true' }}
run: | run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json" dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
dotnet tool install --global dotnet-outdated-tool dotnet tool install --global dotnet-outdated-tool
dotnet outdated -pre Always -u -inc Jellyfin dotnet outdated -pre Always -u -inc Jellyfin
- name: Restore dependencies
if: ${{env.IS_BETA == 'false' }}
run: dotnet restore
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 uses: github/codeql-action/init@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
with: with:

View File

@ -5,6 +5,7 @@ on:
permissions: permissions:
contents: write contents: write
packages: write
jobs: jobs:
build: build:
@ -13,6 +14,22 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Read version from VERSION.txt
id: read-version
run: |
MAIN_VERSION=$(cat VERSION.txt)
echo "MAIN_VERSION=${MAIN_VERSION}"
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV
- name: Check for BETA file
id: check-beta
run: |
if [ -f "BETA" ]; then
echo "IS_BETA=true" >> $GITHUB_ENV
else
echo "IS_BETA=false" >> $GITHUB_ENV
fi
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
@ -25,11 +42,19 @@ jobs:
- name: Minify HTML - name: Minify HTML
run: | 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 html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o IntroSkipper/Configuration/configPage.html IntroSkipper/Configuration/configPage.html
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m npx terser IntroSkipper/Configuration/inject.js -o IntroSkipper/Configuration/inject.js -c -m
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -c -m npx terser IntroSkipper/Configuration/visualizer.js -o IntroSkipper/Configuration/visualizer.js -c -m
- name: Restore Beta dependencies
if: ${{env.IS_BETA == 'true' }}
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
dotnet tool install --global dotnet-outdated-tool
dotnet outdated -pre Always -u -inc Jellyfin
- name: Restore dependencies - name: Restore dependencies
if: ${{env.IS_BETA == 'false' }}
run: dotnet restore run: dotnet restore
- name: Run update version - name: Run update version
@ -40,23 +65,23 @@ jobs:
- name: Embed version info - name: Embed version info
run: | run: |
GITHUB_SHA=${{ github.sha }} GITHUB_SHA=${{ github.sha }}
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" ConfusedPolarBear.Plugin.IntroSkipper/Helper/Commit.cs sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" IntroSkipper/Helper/Commit.cs
- name: Build - name: Build
run: dotnet build --configuration Release --no-restore run: dotnet build --configuration Release --no-restore
- name: Create archive - name: Create archive
run: zip -j "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" ConfusedPolarBear.Plugin.IntroSkipper/bin/Release/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll run: zip -j "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" IntroSkipper/bin/Release/net8.0/IntroSkipper.dll
- name: Remove old release if exits - name: Remove old release if exits
if: ${{ github.repository == 'intro-skipper/intro-skipper-test' }} if: ${{ github.repository == 'intro-skipper/intro-skipper-test' }}
run: gh release delete "10.9/v${{ env.NEW_FILE_VERSION }}" --cleanup-tag --yes || true run: gh release delete "${{ env.MAIN_VERSION }}/v${{ env.NEW_FILE_VERSION }}" --cleanup-tag --yes || true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create new release with tag - name: Create new release with tag
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
run: gh release create "10.9/v${{ env.NEW_FILE_VERSION }}" "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" --title "v${{ env.NEW_FILE_VERSION }}" --latest --generate-notes run: gh release create "${{ env.MAIN_VERSION }}/v${{ env.NEW_FILE_VERSION }}" "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" --title "v${{ env.NEW_FILE_VERSION }}" --latest --generate-notes --target ${{ env.MAIN_VERSION }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -66,12 +91,13 @@ jobs:
task-type: "updateManifest" task-type: "updateManifest"
env: env:
GITHUB_REPO_VISIBILITY: ${{ github.event.repository.visibility }} GITHUB_REPO_VISIBILITY: ${{ github.event.repository.visibility }}
MAIN_VERSION: ${{ env.MAIN_VERSION }}
- name: Commit changes - name: Commit changes
if: success() if: success()
run: | run: |
git config --global user.name "github-actions[bot]" git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add README.md manifest.json ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj .github/ISSUE_TEMPLATE/bug_report_form.yml git add README.md manifest.json IntroSkipper/IntroSkipper.csproj .github/ISSUE_TEMPLATE/bug_report_form.yml
git commit -m "release v${{ env.NEW_FILE_VERSION }}" git commit -m "release v${{ env.NEW_FILE_VERSION }}"
git push git push

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ docker/dist
# Visual Studio # Visual Studio
.vs/ .vs/
# JetBrains Rider
.idea/

11
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"recommendations": [
"ms-dotnettools.csharp",
"editorconfig.editorconfig",
"github.vscode-github-actions",
"ms-dotnettools.vscode-dotnet-runtime",
"ms-dotnettools.csdevkit",
"eamodio.gitlens"
],
"unwantedRecommendations": []
}

9
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"editor.insertSpaces": true,
"editor.tabSize": 4,
"files.encoding": "utf8",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"editor.rulers": [],
"dotnet.defaultSolution": "IntroSkipper.sln"
}

View File

@ -1,471 +0,0 @@
const introSkipper = {
originalFetch: window.fetch.bind(window),
d: msg => console.debug("[intro skipper] ", msg),
setup() {
this.initializeState();
this.initializeObserver();
this.currentOption = localStorage.getItem('introskipperOption') || 'Show Button';
document.addEventListener("viewshow", this.viewShow.bind(this));
window.fetch = this.fetchWrapper.bind(this);
this.videoPositionChanged = this.videoPositionChanged.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.d("Registered hooks");
},
initializeState() {
Object.assign(this, { allowEnter: true, skipSegments: {}, videoPlayer: null, skipButton: null, osdElement: null, skipperData: null, currentEpisodeId: null, injectMetadata: false });
},
initializeObserver() {
this.observer = new MutationObserver(mutations => {
const actionSheet = mutations[mutations.length - 1].target.querySelector('.actionSheet');
if (actionSheet && !actionSheet.querySelector(`[data-id="${'introskipperMenu'}"]`)) this.injectIntroSkipperOptions(actionSheet);
});
},
/** Wrapper around fetch() that retrieves skip segments for the currently playing item or metadata. */
async fetchWrapper(resource, options) {
const response = await this.originalFetch(resource, options);
this.processResource(resource);
return response;
},
async processResource(resource) {
try {
const url = new URL(resource);
const pathname = url.pathname;
if (pathname.includes("/PlaybackInfo")) {
this.d(`Retrieving skip segments from URL ${pathname}`);
const pathArr = pathname.split("/");
const id = pathArr[pathArr.indexOf("Items") + 1] || pathArr[3];
this.skipSegments = await this.secureFetch(`Episode/${id}/IntroSkipperSegments`);
this.d("Retrieved skip segments", this.skipSegments);
} else if (this.injectMetadata && pathname.includes("/MetadataEditor")) {
this.d(`Metadata editor detected, URL ${pathname}`);
const pathArr = pathname.split("/");
this.currentEpisodeId = pathArr[pathArr.indexOf("Items") + 1] || pathArr[3];
this.skipperData = await this.secureFetch(`Episode/${this.currentEpisodeId}/Timestamps`);
if (this.skipperData) {
requestAnimationFrame(() => {
const metadataFormFields = document.querySelector('.metadataFormFields');
metadataFormFields && this.injectSkipperFields(metadataFormFields);
});
}
}
} catch (e) {
console.error("Error processing", resource, e);
}
},
/**
* Event handler that runs whenever the current view changes.
* Used to detect the start of video playback.
*/
viewShow() {
const location = window.location.hash;
this.d(`Location changed to ${location}`);
this.allowEnter = true;
this.injectMetadata = /#\/(tv|details|home|search)/.test(location);
if (location === "#/video") {
this.injectCss();
this.injectButton();
this.videoPlayer = document.querySelector("video");
if (this.videoPlayer) {
this.d("Hooking video timeupdate");
this.videoPlayer.addEventListener("timeupdate", this.videoPositionChanged);
this.osdElement = document.querySelector("div.videoOsdBottom")
this.observer.observe(document.body, { childList: true, subtree: false });
}
}
else {
this.observer.disconnect();
}
},
/**
* Injects the CSS used by the skip intro button.
* Calling this function is a no-op if the CSS has already been injected.
*/
injectCss() {
if (document.querySelector("style#introSkipperCss")) {
this.d("CSS already added");
return;
}
this.d("Adding CSS");
const styleElement = document.createElement("style");
styleElement.id = "introSkipperCss";
styleElement.textContent = `
:root {
--rounding: 4px;
--accent: 0, 164, 220;
}
#skipIntro.upNextContainer {
width: unset;
margin: unset;
}
#skipIntro {
position: absolute;
bottom: 7.5em;
right: 5em;
background-color: transparent;
}
#skipIntro .emby-button {
color: #ffffff;
font-size: 110%;
background: rgba(0, 0, 0, 0.7);
border-radius: var(--rounding);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
transition: opacity 0.3s cubic-bezier(0.4,0,0.2,1),
transform 0.3s cubic-bezier(0.4,0,0.2,1),
background-color 0.2s ease-out,
box-shadow 0.2s ease-out;
opacity: 0;
transform: translateY(50%);
}
#skipIntro.show .emby-button {
opacity: 1;
transform: translateY(0);
}
#skipIntro .emby-button:hover {
background: rgb(var(--accent));
box-shadow: 0 0 8px rgba(var(--accent), 0.6);
filter: brightness(1.2);
}
#skipIntro .emby-button:focus {
background: rgb(var(--accent));
box-shadow: 0 0 8px rgba(var(--accent), 0.6);
}
#btnSkipSegmentText {
letter-spacing: 0.5px;
padding: 0 5px 0 5px;
}
`;
document.querySelector("head").appendChild(styleElement);
},
/**
* Inject the skip intro button into the video player.
* Calling this function is a no-op if the CSS has already been injected.
*/
async injectButton() {
// Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
const preExistingButton = document.querySelector("div.skipIntro");
if (preExistingButton) {
preExistingButton.style.display = "none";
}
if (document.querySelector(".btnSkipIntro.injected")) {
this.d("Button already added");
this.skipButton = document.querySelector("#skipIntro");
return;
}
const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
if (!config.SkipButtonVisible) {
this.d("Not adding button: not visible");
return;
}
this.d("Adding button");
this.skipButton = document.createElement("div");
this.skipButton.id = "skipIntro";
this.skipButton.classList.add("hide", "upNextContainer");
this.skipButton.addEventListener("click", this.doSkip.bind(this));
this.skipButton.addEventListener("keydown", this.eventHandler.bind(this));
this.skipButton.innerHTML = `
<button is="emby-button" type="button" class="btnSkipIntro injected">
<span id="btnSkipSegmentText"></span>
<span class="material-icons skip_next"></span>
</button>
`;
this.skipButton.dataset.Introduction = config.SkipButtonIntroText;
this.skipButton.dataset.Credits = config.SkipButtonEndCreditsText;
const controls = document.querySelector("div#videoOsdPage");
controls.appendChild(this.skipButton);
},
/** Tests if the OSD controls are visible. */
osdVisible() {
return this.osdElement ? !this.osdElement.classList.contains("hide") : false;
},
/** Get the currently playing skippable segment. */
getCurrentSegment(position) {
for (const [key, segment] of Object.entries(this.skipSegments)) {
if ((position > segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt - 1) ||
(this.osdVisible() && position > segment.IntroStart && position < segment.IntroEnd - 1)) {
segment.SegmentType = key;
return segment;
}
}
return { SegmentType: "None" };
},
overrideBlur(button) {
if (!button.originalBlur) {
button.originalBlur = button.blur;
button.blur = function() {
if (!this.contains(document.activeElement)) {
this.originalBlur();
}
};
}
},
/** Playback position changed, check if the skip button needs to be displayed. */
videoPositionChanged() {
if (!this.skipButton) return;
const embyButton = this.skipButton.querySelector(".emby-button");
const segmentType = this.getCurrentSegment(this.videoPlayer.currentTime).SegmentType;
if (segmentType === "None" || this.currentOption === "Off" || !this.allowEnter) {
if (this.skipButton.classList.contains('show')) {
this.skipButton.classList.remove('show');
embyButton.addEventListener("transitionend", () => {
this.skipButton.classList.add("hide");
if (this.osdVisible()) {
this.osdElement.querySelector('button.btnPause').focus();
} else {
embyButton.originalBlur();
}
}, { once: true });
}
return;
}
if (this.currentOption === "Automatically Skip" || (this.currentOption === "Button w/ auto PiP" && document.pictureInPictureElement)) {
this.doSkip();
return;
}
this.skipButton.querySelector("#btnSkipSegmentText").textContent = this.skipButton.dataset[segmentType];
if (!this.skipButton.classList.contains("hide")) {
if (!this.osdVisible() && !embyButton.contains(document.activeElement)) embyButton.focus();
return;
}
requestAnimationFrame(() => {
this.skipButton.classList.remove("hide");
requestAnimationFrame(() => {
this.skipButton.classList.add('show');
this.overrideBlur(embyButton);
embyButton.focus();
});
});
},
/** Seeks to the end of the intro. */
doSkip() {
if (!this.allowEnter) return;
const segment = this.getCurrentSegment(this.videoPlayer.currentTime);
if (segment.SegmentType === "None") {
console.warn("[intro skipper] doSkip() called without an active segment");
return;
}
this.d(`Skipping ${segment.SegmentType}`);
this.allowEnter = false;
const seekedHandler = () => {
this.videoPlayer.removeEventListener('seeked', seekedHandler);
setTimeout(() => {
this.allowEnter = true;
}, 500);
};
this.videoPlayer.addEventListener('seeked', seekedHandler);
this.videoPlayer.currentTime = segment.SegmentType === "Credits" && this.videoPlayer.duration - segment.IntroEnd < 3
? this.videoPlayer.duration + 10
: segment.IntroEnd;
},
createButton(ref, id, innerHTML, clickHandler) {
const button = ref.cloneNode(true);
button.setAttribute('data-id', id);
button.innerHTML = innerHTML;
button.addEventListener('click', clickHandler);
return button;
},
closeSubmenu(fullscreen) {
document.querySelector('.dialogContainer').remove();
document.querySelector('.dialogBackdrop').remove()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Control' }));
if (!fullscreen) return;
document.removeEventListener('keydown', this.handleEscapeKey);
document.querySelector('.btnVideoOsdSettings').focus();
},
openSubmenu(ref, menu) {
const options = ['Show Button', 'Button w/ auto PiP', 'Automatically Skip', 'Off'];
const submenu = menu.cloneNode(true);
const scroller = submenu.querySelector('.actionSheetScroller');
scroller.innerHTML = '';
options.forEach(option => {
if (option !== 'Button w/ auto PiP' || document.pictureInPictureEnabled) {
const button = this.createButton(ref, `introskipper-${option.toLowerCase().replace(' ', '-')}`,
`<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons check" aria-hidden="true" style="visibility:${option === this.currentOption ? 'visible' : 'hidden'};"></span><div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">${option}</div></div>`,
() => this.selectOption(option));
scroller.appendChild(button);
}
});
const backdrop = document.createElement('div');
backdrop.className = 'dialogBackdrop dialogBackdropOpened';
document.body.append(backdrop, submenu);
const actionSheet = submenu.querySelector('.actionSheet');
if (actionSheet.classList.contains('actionsheet-not-fullscreen')) {
this.adjustPosition(actionSheet, document.querySelector('.btnVideoOsdSettings'));
submenu.addEventListener('click', () => this.closeSubmenu(false));
} else {
submenu.querySelector('.btnCloseActionSheet').addEventListener('click', () => this.closeSubmenu(true))
scroller.addEventListener('click', () => this.closeSubmenu(true))
document.addEventListener('keydown', this.handleEscapeKey);
setTimeout(() => scroller.firstElementChild.focus(), 240);
}
},
selectOption(option) {
this.currentOption = option;
localStorage.setItem('introskipperOption', option);
this.d(`Introskipper option selected and saved: ${option}`);
},
isAutoSkipLocked(config) {
const isAutoSkip = config.AutoSkip && config.AutoSkipCredits;
const isAutoSkipClient = new Set(config.ClientList.split(',')).has(ApiClient.appName());
return isAutoSkip || (config.SkipButtonVisible && isAutoSkipClient);
},
async injectIntroSkipperOptions(actionSheet) {
if (!this.skipButton) return;
const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
if (this.isAutoSkipLocked(config)) {
this.d("Auto skip enforced by server");
return;
}
const statsButton = actionSheet.querySelector('[data-id="stats"]');
if (!statsButton) return;
const menuItem = this.createButton(statsButton, 'introskipperMenu',
`<div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">Intro Skipper</div></div><div class="listItemAside actionSheetItemAsideText">${this.currentOption}</div>`,
() => this.openSubmenu(statsButton, actionSheet.closest('.dialogContainer')));
const originalWidth = actionSheet.offsetWidth;
statsButton.before(menuItem);
if (actionSheet.classList.contains('actionsheet-not-fullscreen')) this.adjustPosition(actionSheet, menuItem, originalWidth);
},
adjustPosition(element, reference, originalWidth) {
if (originalWidth) {
const currentTop = parseInt(element.style.top, 10) || 0;
element.style.top = `${currentTop - reference.offsetHeight}px`;
const newWidth = Math.max(reference.offsetWidth - originalWidth, 0);
const originalLeft = parseInt(element.style.left, 10) || 0;
element.style.left = `${originalLeft - newWidth / 2}px`;
} else {
const rect = reference.getBoundingClientRect();
element.style.left = `${Math.min(rect.left - (element.offsetWidth - rect.width) / 2, window.innerWidth - element.offsetWidth - 10)}px`;
element.style.top = `${rect.top - element.offsetHeight + rect.height}px`;
}
},
injectSkipperFields(metadataFormFields) {
const skipperFields = document.createElement('div');
skipperFields.className = 'detailSection introskipperSection';
skipperFields.innerHTML = `
<h2>Intro Skipper</h2>
<div class="inlineForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label>
<input type="text" id="introStartDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="introStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label>
<input type="text" id="introEndDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="introEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
</div>
<div class="inlineForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
<input type="text" id="creditsStartDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="creditsStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="creditsEnd">Credits End</label>
<input type="text" id="creditsEndDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="creditsEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
</div>
`;
metadataFormFields.querySelector('#metadataSettingsCollapsible').insertAdjacentElement('afterend', skipperFields);
this.attachSaveListener(metadataFormFields);
this.updateSkipperFields(skipperFields);
this.setTimeInputs(skipperFields);
},
updateSkipperFields(skipperFields) {
const { Introduction = {}, Credits = {} } = this.skipperData;
skipperFields.querySelector('#introStartEdit').value = Introduction.Start || 0;
skipperFields.querySelector('#introEndEdit').value = Introduction.End || 0;
skipperFields.querySelector('#creditsStartEdit').value = Credits.Start || 0;
skipperFields.querySelector('#creditsEndEdit').value = Credits.End || 0;
},
attachSaveListener(metadataFormFields) {
const saveButton = metadataFormFields.querySelector('.formDialogFooter .btnSave');
if (saveButton) {
saveButton.addEventListener('click', this.saveSkipperData.bind(this));
} else {
console.error('Save button not found');
}
},
setTimeInputs(skipperFields) {
const inputContainers = skipperFields.querySelectorAll('.inputContainer');
inputContainers.forEach(container => {
const displayInput = container.querySelector('[id$="Display"]');
const editInput = container.querySelector('[id$="Edit"]');
displayInput.addEventListener('pointerdown', (e) => {
e.preventDefault();
this.switchToEdit(displayInput, editInput);
});
editInput.addEventListener('blur', () => this.switchToDisplay(displayInput, editInput));
displayInput.value = this.formatTime(parseFloat(editInput.value) || 0);
});
},
formatTime(totalSeconds) {
const totalRoundedSeconds = Math.round(totalSeconds);
const hours = Math.floor(totalRoundedSeconds / 3600);
const minutes = Math.floor((totalRoundedSeconds % 3600) / 60);
const seconds = totalRoundedSeconds % 60;
let result = [];
if (hours > 0) result.push(`${hours} hour${hours !== 1 ? 's' : ''}`);
if (minutes > 0) result.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`);
if (seconds > 0 || result.length === 0) result.push(`${seconds} second${seconds !== 1 ? 's' : ''}`);
return result.join(' ');
},
switchToEdit(displayInput, editInput) {
displayInput.style.display = 'none';
editInput.style.display = '';
editInput.focus();
},
switchToDisplay(displayInput, editInput) {
editInput.style.display = 'none';
displayInput.style.display = '';
displayInput.value = this.formatTime(parseFloat(editInput.value) || 0);
},
async saveSkipperData() {
const newTimestamps = {
Introduction: {
Start: parseFloat(document.getElementById('introStartEdit').value || 0),
End: parseFloat(document.getElementById('introEndEdit').value || 0)
},
Credits: {
Start: parseFloat(document.getElementById('creditsStartEdit').value || 0),
End: parseFloat(document.getElementById('creditsEndEdit').value || 0)
}
};
const { Introduction = {}, Credits = {} } = this.skipperData;
if (newTimestamps.Introduction.Start !== (Introduction.Start || 0) ||
newTimestamps.Introduction.End !== (Introduction.End || 0) ||
newTimestamps.Credits.Start !== (Credits.Start || 0) ||
newTimestamps.Credits.End !== (Credits.End || 0)) {
const response = await this.secureFetch(`Episode/${this.currentEpisodeId}/Timestamps`, "POST", JSON.stringify(newTimestamps));
this.d(response.ok ? 'Timestamps updated successfully' : 'Failed to update timestamps:', response.status);
} else {
this.d('Timestamps have not changed, skipping update');
}
},
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
async secureFetch(url, method = "GET", body = null) {
const response = await fetch(`${ApiClient.serverAddress()}/${url}`, {
method,
headers: Object.assign({ "Authorization": `MediaBrowser Token=${ApiClient.accessToken()}` },
method === "POST" ? {"Content-Type": "application/json"} : {}),
body });
return response.ok ? (method === "POST" ? response : response.json()) :
response.status === 404 ? null :
console.error(`Error ${response.status} from ${url}`) || null;
},
/** Handle keydown events. */
eventHandler(e) {
if (e.key !== "Enter") return;
e.stopPropagation();
e.preventDefault();
this.doSkip();
},
handleEscapeKey(e) {
if (e.key === 'Escape' || e.keyCode === 461 || e.keyCode === 10009) {
e.stopPropagation();
this.closeSubmenu(true);
}
}
};
introSkipper.setup();

View File

@ -21,7 +21,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj" /> <ProjectReference Include="..\IntroSkipper\IntroSkipper.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,15 +1,18 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
/* These tests require that the host system has a version of FFmpeg installed /* These tests require that the host system has a version of FFmpeg installed
* which supports both chromaprint and the "-fp_format raw" flag. * which supports both chromaprint and the "-fp_format raw" flag.
*/ */
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; using IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Xunit; using Xunit;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; namespace IntroSkipper.Tests;
public class TestAudioFingerprinting public class TestAudioFingerprinting
{ {

View File

@ -1,9 +1,12 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Tests;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; using IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Xunit; using Xunit;

View File

@ -1,10 +1,13 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Tests;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; using IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Xunit; using Xunit;

View File

@ -1,7 +1,10 @@
using ConfusedPolarBear.Plugin.IntroSkipper.Data; // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using IntroSkipper.Data;
using Xunit; using Xunit;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; namespace IntroSkipper.Tests;
public class TestTimeRanges public class TestTimeRanges
{ {

View File

@ -1,8 +1,11 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using Xunit; using Xunit;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; namespace IntroSkipper.Tests;
public class TestEdl public class TestEdl
{ {

View File

@ -1,6 +1,9 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using ConfusedPolarBear.Plugin.IntroSkipper.Data; namespace IntroSkipper.Tests;
using IntroSkipper.Data;
using Xunit; using Xunit;
public class TestFlags public class TestFlags

View File

@ -1,8 +1,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# #
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper", "ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntroSkipper", "IntroSkipper\IntroSkipper.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper.Tests", "ConfusedPolarBear.Plugin.IntroSkipper.Tests\ConfusedPolarBear.Plugin.IntroSkipper.Tests.csproj", "{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntroSkipper.Tests", "IntroSkipper.Tests\IntroSkipper.Tests.csproj", "{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution

View File

@ -1,11 +1,14 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace IntroSkipper;
/// <summary> /// <summary>
/// Analyzer Helper. /// Analyzer Helper.

View File

@ -1,12 +1,15 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; namespace IntroSkipper.Analyzers;
/// <summary> /// <summary>
/// Media file analyzer used to detect end credits that consist of text overlaid on a black background. /// Media file analyzer used to detect end credits that consist of text overlaid on a black background.
@ -22,6 +25,8 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
private readonly int _maximumCreditsDuration; private readonly int _maximumCreditsDuration;
private readonly int _maximumMovieCreditsDuration;
private readonly int _blackFrameMinimumPercentage; private readonly int _blackFrameMinimumPercentage;
/// <summary> /// <summary>
@ -32,7 +37,8 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
{ {
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
_minimumCreditsDuration = config.MinimumCreditsDuration; _minimumCreditsDuration = config.MinimumCreditsDuration;
_maximumCreditsDuration = 2 * config.MaximumCreditsDuration; _maximumCreditsDuration = config.MaximumCreditsDuration;
_maximumMovieCreditsDuration = config.MaximumMovieCreditsDuration;
_blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage; _blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage;
_logger = logger; _logger = logger;
@ -66,7 +72,21 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
break; break;
} }
// Pre-check to find reasonable starting point. var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration;
var chapters = Plugin.Instance!.GetChapters(episode.EpisodeId);
var lastSuitableChapter = chapters.LastOrDefault(c =>
{
var start = TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds;
return start >= _minimumCreditsDuration && start <= creditDuration;
});
if (lastSuitableChapter is not null)
{
searchStart = TimeSpan.FromTicks(lastSuitableChapter.StartPositionTicks).TotalSeconds;
isFirstEpisode = false;
}
if (isFirstEpisode) if (isFirstEpisode)
{ {
var scanTime = episode.Duration - searchStart; var scanTime = episode.Duration - searchStart;
@ -83,9 +103,9 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage); frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
if (searchStart > _maximumCreditsDuration) if (searchStart > creditDuration)
{ {
searchStart = _maximumCreditsDuration; searchStart = creditDuration;
break; break;
} }
} }
@ -104,7 +124,7 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
searchDistance, searchDistance,
_blackFrameMinimumPercentage); _blackFrameMinimumPercentage);
if (credit is null) if (credit is null || !credit.Valid)
{ {
// If no credits were found, reset the first-episode search logic for the next episode in the sequence. // If no credits were found, reset the first-episode search logic for the next episode in the sequence.
searchStart = _minimumCreditsDuration; searchStart = _minimumCreditsDuration;
@ -143,6 +163,8 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
var end = TimeSpan.FromSeconds(lowerLimit); var end = TimeSpan.FromSeconds(lowerLimit);
var firstFrameTime = 0.0; var firstFrameTime = 0.0;
var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration;
// Continue bisecting the end of the file until the range that contains the first black // Continue bisecting the end of the file until the range that contains the first black
// frame is smaller than the maximum permitted error. // frame is smaller than the maximum permitted error.
while (start - end > _maximumError) while (start - end > _maximumError)
@ -189,7 +211,7 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError) if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError)
{ {
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), _maximumCreditsDuration); upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), creditDuration);
// Reset start for a new search with the increased duration // Reset start for a new search with the increased duration
start = TimeSpan.FromSeconds(upperLimit); start = TimeSpan.FromSeconds(upperLimit);

View File

@ -1,16 +1,18 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; namespace IntroSkipper.Analyzers;
/// <summary> /// <summary>
/// Chapter name analyzer. /// Chapter name analyzer.
@ -56,7 +58,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
expression, expression,
mode); mode);
if (skipRange is null) if (skipRange is null || !skipRange.Valid)
{ {
continue; continue;
} }
@ -92,9 +94,10 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
} }
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var creditDuration = episode.IsMovie ? config.MaximumMovieCreditsDuration : config.MaximumCreditsDuration;
var reversed = mode != AnalysisMode.Introduction; var reversed = mode != AnalysisMode.Introduction;
var (minDuration, maxDuration) = reversed var (minDuration, maxDuration) = reversed
? (config.MinimumCreditsDuration, config.MaximumCreditsDuration) ? (config.MinimumCreditsDuration, creditDuration)
: (config.MinimumIntroDuration, config.MaximumIntroDuration); : (config.MinimumIntroDuration, config.MaximumIntroDuration);
// Check all chapters // Check all chapters

View File

@ -1,14 +1,17 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Numerics; using System.Numerics;
using System.Threading; using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; namespace IntroSkipper.Analyzers;
/// <summary> /// <summary>
/// Chromaprint audio analyzer. /// Chromaprint audio analyzer.

View File

@ -1,8 +1,11 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; namespace IntroSkipper.Analyzers;
/// <summary> /// <summary>
/// Media file analyzer interface. /// Media file analyzer interface.

View File

@ -1,9 +1,12 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; namespace IntroSkipper.Analyzers;
/// <summary> /// <summary>
/// Chapter name analyzer. /// Chapter name analyzer.

View File

@ -1,8 +1,11 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System.Diagnostics; using System.Diagnostics;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration; namespace IntroSkipper.Configuration;
/// <summary> /// <summary>
/// Plugin configuration. /// Plugin configuration.
@ -18,11 +21,6 @@ public class PluginConfiguration : BasePluginConfiguration
// ===== Analysis settings ===== // ===== Analysis settings =====
/// <summary>
/// Gets or sets the max degree of parallelism used when analyzing episodes.
/// </summary>
public int MaxParallelism { get; set; } = 2;
/// <summary> /// <summary>
/// Gets or sets the comma separated list of library names to analyze. /// Gets or sets the comma separated list of library names to analyze.
/// </summary> /// </summary>
@ -33,6 +31,11 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public bool SelectAllLibraries { get; set; } = true; public bool SelectAllLibraries { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether movies should be analyzed.
/// </summary>
public bool AnalyzeMovies { get; set; }
/// <summary> /// <summary>
/// Gets or sets the list of client to auto skip for. /// Gets or sets the list of client to auto skip for.
/// </summary> /// </summary>
@ -107,7 +110,12 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary> /// <summary>
/// Gets or sets the upper limit (in seconds) on the length of each episode's audio track that will be analyzed when searching for ending credits. /// Gets or sets the upper limit (in seconds) on the length of each episode's audio track that will be analyzed when searching for ending credits.
/// </summary> /// </summary>
public int MaximumCreditsDuration { get; set; } = 300; public int MaximumCreditsDuration { get; set; } = 450;
/// <summary>
/// Gets or sets the upper limit (in seconds) on the length of a movie segment that will be analyzed when searching for ending credits.
/// </summary>
public int MaximumMovieCreditsDuration { get; set; } = 900;
/// <summary> /// <summary>
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame. /// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
@ -235,12 +243,22 @@ public class PluginConfiguration : BasePluginConfiguration
public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped"; public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
/// <summary> /// <summary>
/// Gets or sets the number of threads for an ffmpeg process. /// Gets or sets the max degree of parallelism used when analyzing episodes.
/// </summary>
public int MaxParallelism { get; set; } = 2;
/// <summary>
/// Gets or sets the number of threads for a ffmpeg process.
/// </summary> /// </summary>
public int ProcessThreads { get; set; } public int ProcessThreads { get; set; }
/// <summary> /// <summary>
/// Gets or sets the relative priority for an ffmpeg process. /// Gets or sets the relative priority for a ffmpeg process.
/// </summary> /// </summary>
public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal; public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal;
/// <summary>
/// Gets or sets a value indicating whether the ManifestUrl is self-managed, e.g. for mainland China.
/// </summary>
public bool OverrideManifestUrl { get; set; }
} }

View File

@ -1,4 +1,7 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration; // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Configuration;
/// <summary> /// <summary>
/// User interface configuration. /// User interface configuration.

View File

@ -32,46 +32,43 @@
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="AutoDetectIntros" type="checkbox" is="emby-checkbox" /> <input id="AutoDetectIntros" type="checkbox" is="emby-checkbox" />
<span>Automatically Scan Intros</span> <span>Automatically Analyze Intros</span>
</label> </label>
<div class="fieldDescription">If enabled, new media will be automatically analyzed for introductions</div>
<div class="fieldDescription">If enabled, introductions will be automatically analyzed for new media</div>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="AutoDetectCredits" type="checkbox" is="emby-checkbox" /> <input id="AutoDetectCredits" type="checkbox" is="emby-checkbox" />
<span>Automatically Scan Credits</span> <span>Automatically Analyze Credits</span>
</label> </label>
<div class="fieldDescription"> <div class="fieldDescription">
If enabled, credits will be automatically analyzed for new media If enabled, new media will be automatically analyzed for credits
<br /> <br />
<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="#/dashboard/tasks">scheduled tasks</a>. 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="#/dashboard/tasks">scheduled tasks</a>.
</div> </div>
</div> </div>
<div class="checkboxContainer">
<label class="emby-checkbox-label">
<input id="AnalyzeMovies" type="checkbox" is="emby-checkbox" />
<span>Analyze Movies</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" /> <input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" />
<span>Analyze season 0</span> <span>Analyze Season 0 (Specials / Extras)</span>
</label> </label>
<div class="fieldDescription"> <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. Note: Shows containing both a specials and extra folder will identify extras as season 0 and ignore specials, regardless of this setting.
</div> </div>
</div> </div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="MaxParallelism"> Maximum degree of parallelism </label>
<input id="MaxParallelism" type="number" is="emby-input" min="1" />
<div class="fieldDescription">Maximum number of simultaneous async episode analysis operations.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="SelectAllLibraries" type="checkbox" is="emby-checkbox" /> <input id="SelectAllLibraries" type="checkbox" is="emby-checkbox" />
@ -128,13 +125,11 @@
<div class="fieldDescription">Similar sounding audio which is longer than this duration will not be considered credits.</div> <div class="fieldDescription">Similar sounding audio which is longer than this duration will not be considered credits.</div>
</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 (episode duration * percent, maximum runtime) is the amount of audio that will be analyzed.</p> <div class="inputContainer" id="movieCreditsDuration">
<label class="inputLabel inputLabelUnfocused" for="MaximumMovieCreditsDuration"> Maximum movie credits duration (in seconds) </label>
<p> <input id="MaximumMovieCreditsDuration" type="number" is="emby-input" min="1" />
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> <div class="fieldDescription">Segments longer than this duration will not be considered movie credits.</div>
</div>
Increasing either of these settings will cause episode analysis to take much longer.
</p>
</details> </details>
<details id="edl"> <details id="edl">
@ -150,8 +145,6 @@
<option value="Cut">Cut (player will remove the intro from the video)</option> <option value="Cut">Cut (player will remove the intro from the video)</option>
<option value="Intro">Intro/Credit (show a skip button, *experimental*)</option>
<option value="Mute">Mute (audio will be muted)</option> <option value="Mute">Mute (audio will be muted)</option>
<option value="SceneMarker">Scene Marker (create a chapter marker)</option> <option value="SceneMarker">Scene Marker (create a chapter marker)</option>
@ -166,13 +159,23 @@
</div> </div>
</div> </div>
<p>
The amount of each item's audio or content that will be analyzed is determined using both the percentage of audio and maximum runtime to analyze. The minimum of (duration * percent, maximum runtime) is the amount that will be analyzed.
</p>
<p>
If the audio percentage or maximum runtime settings are modified, the cached fingerprints and timestamps for each series, season, or movie you want to analyze with the modified settings <b>will have to be deleted</b>.
<br />
Increasing either of these settings will cause episode analysis to take much longer.
</p>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="RegenerateEdlFiles" type="checkbox" is="emby-checkbox" /> <input id="RegenerateEdlFiles" type="checkbox" is="emby-checkbox" />
<span>Regenerate EDL files during next scan</span> <span>Regenerate EDL files during next scan</span>
</label> </label>
<div class="fieldDescription">If checked, the plugin will <strong>overwrite all EDL files</strong> associated with your episodes with the currently discovered introduction/credit timestamps and EDL action.</div> <div class="fieldDescription">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> </div>
</details> </details>
@ -197,6 +200,12 @@
<summary>Process Configuration</summary> <summary>Process Configuration</summary>
<br /> <br />
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="MaxParallelism"> Maximum degree of parallelism </label>
<input id="MaxParallelism" type="number" is="emby-input" min="1" />
<div class="fieldDescription">Maximum number of simultaneous async episode analysis operations.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="CacheFingerprints" type="checkbox" is="emby-checkbox" /> <input id="CacheFingerprints" type="checkbox" is="emby-checkbox" />
@ -206,7 +215,7 @@
<div class="fieldDescription"> <div class="fieldDescription">
If checked, episode fingerprints will be saved on the filesystem to improve analysis speed. If checked, episode fingerprints will be saved on the filesystem to improve analysis speed.
<br /> <br />
<strong>WARNING: Disabling the cache will cause all libraries to be re-scanned, which can take a very long time!</strong> <b>WARNING: Disabling the cache will cause all libraries to be re-scanned, which can take a very long time!</b>
<br /> <br />
</div> </div>
</div> </div>
@ -248,11 +257,11 @@
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="AutoSkip" type="checkbox" is="emby-checkbox" /> <input id="AutoSkip" type="checkbox" is="emby-checkbox" />
<span>Automatically skip intros</span> <span>Automatically Skip Intros</span>
</label> </label>
<div class="fieldDescription"> <div class="fieldDescription">
If checked, intros will be automatically skipped for <strong>all</strong> clients. Note: Clients cannot disable this setting from the player popup (gear icon).<br /> If checked, intros will be automatically skipped for <b>all</b> clients. Note: Clients cannot disable this setting from the player popup (gear icon).<br />
If you access Jellyfin through a reverse proxy, it must be configured to proxy websockets.<br /> If you access Jellyfin through a reverse proxy, it must be configured to proxy websockets.<br />
</div> </div>
</div> </div>
@ -260,7 +269,7 @@
<div id="divSkipFirstEpisode" class="checkboxContainer checkboxContainer-withDescription"> <div id="divSkipFirstEpisode" class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="SkipFirstEpisode" type="checkbox" is="emby-checkbox" /> <input id="SkipFirstEpisode" type="checkbox" is="emby-checkbox" />
<span>Play intro for first episode of a season</span> <span>Play Intro for First Episode of a Season</span>
</label> </label>
<div class="fieldDescription">If checked, auto skip will play the introduction of the first episode in a season.<br /></div> <div class="fieldDescription">If checked, auto skip will play the introduction of the first episode in a season.<br /></div>
@ -274,14 +283,20 @@
<br /> <br />
</div> </div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RemainingSecondsOfIntro"> Intro playback duration (in seconds) </label>
<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>
</div>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="AutoSkipCredits" type="checkbox" is="emby-checkbox" /> <input id="AutoSkipCredits" type="checkbox" is="emby-checkbox" />
<span>Automatically skip credits</span> <span>Automatically Skip Credits</span>
</label> </label>
<div class="fieldDescription"> <div class="fieldDescription">
If checked, credits will be automatically skipped for <strong>all</strong> clients. Note: Clients cannot disable this setting from the player popup (gear icon).<br /> If checked, credits will be automatically skipped for <b>all</b> clients. Note: Clients cannot disable this setting from the player popup (gear icon).<br />
If you access Jellyfin through a reverse proxy, it must be configured to proxy websockets.<br /> If you access Jellyfin through a reverse proxy, it must be configured to proxy websockets.<br />
</div> </div>
</div> </div>
@ -296,16 +311,18 @@
<div id="SkipButtonContainer" class="checkboxContainer checkboxContainer-withDescription"> <div id="SkipButtonContainer" class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="SkipButtonVisible" type="checkbox" is="emby-checkbox" /> <input id="SkipButtonVisible" type="checkbox" is="emby-checkbox" />
<span id="SkipButtonVisibleLabel">Show skip intro / credit button</span> <span id="SkipButtonVisibleLabel">Show All Skip Buttons</span>
</label> </label>
<div class="fieldDescription"> <div class="fieldDescription">
If checked, a skip button will be displayed according to the settings below, while clients selected in the Auto Skip Client List will skip <strong>automatically</strong>. If checked, a skip button will be displayed according to the settings below, while clients selected in the Auto Skip Client List will skip <b>automatically</b>.
<br /> <br />
</div> </div>
</div> </div>
<div id="warningMessage" style="color: #721c24; background-color: #f7cf1f; border: 1px solid #f5c6cb; border-radius: 4px; padding: 10px; margin-bottom: 10px">Failed to add skip button to web interface. See <a href="https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible" target="_blank" rel="noopener noreferrer">troubleshooting guide</a> for the most common issues.</div> <div id="warningMessage" style="color: #721c24; background-color: #f7cf1f; border: 1px solid #f5c6cb; border-radius: 4px; padding: 10px; margin-bottom: 10px">
Failed to add skip button to web interface. See <a href="https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible" target="_blank" rel="noopener noreferrer">troubleshooting guide</a> for the most common issues.
</div>
<details id="AutoSkipClientList" style="padding-bottom: 1em"> <details id="AutoSkipClientList" style="padding-bottom: 1em">
<summary>Auto Skip Client List</summary> <summary>Auto Skip Client List</summary>
@ -315,45 +332,45 @@
<input id="ClientList" type="hidden" is="emby-input" /> <input id="ClientList" type="hidden" is="emby-input" />
</details> </details>
<div id="SkipButtonSettings">
<div id="PersistContainer" class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="PersistSkipButton" type="checkbox" is="emby-checkbox" />
<span>Display button for intro duration</span>
</label>
<div class="fieldDescription">
If checked, skip button will remain visible throught the intro (offset and timeout are ignored).
<br />
Note: If unchecked, button will only appear in the player controls after the set timeout.
</div>
</div>
<div id="divShowPromptAdjustment" class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ShowPromptAdjustment"> Skip prompt offset (in seconds) </label>
<input id="ShowPromptAdjustment" type="number" is="emby-input" min="0" />
<div class="fieldDescription">Seconds to display skip prompt before introduction begins.</div>
<br />
</div>
<div id="divHidePromptAdjustment" class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="HidePromptAdjustment"> Skip prompt timeout (in seconds) </label>
<input id="HidePromptAdjustment" type="number" is="emby-input" min="2" />
<div class="fieldDescription">Seconds after introduction before skip prompt is hidden.</div>
<br />
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RemainingSecondsOfIntro"> Intro playback duration (in seconds) </label>
<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>
</div>
<details> <details>
<summary>User Interface Customization</summary> <summary>User Interface Customization</summary>
<br /> <br />
<div id="SkipButtonSettings">
<div id="PersistContainer" class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="PersistSkipButton" type="checkbox" is="emby-checkbox" />
<span>Display Button for Segment Duration</span>
</label>
<div class="fieldDescription">
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>
</div>
<div id="divShowPromptAdjustment" class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ShowPromptAdjustment"> Skip prompt offset (in seconds) </label>
<input id="ShowPromptAdjustment" type="number" is="emby-input" min="0" />
<div class="fieldDescription">Seconds to display skip prompt before introduction begins.</div>
<br />
</div>
<div id="divHidePromptAdjustment" class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="HidePromptAdjustment"> Skip prompt timeout (in seconds) </label>
<input id="HidePromptAdjustment" type="number" is="emby-input" min="2" />
<div class="fieldDescription">Seconds after introduction before skip prompt is hidden.</div>
<br />
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RemainingSecondsOfIntro"> Intro playback duration (in seconds) </label>
<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>
</div>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel" for="SkipButtonIntroText"> Skip intro button text </label> <label class="inputLabel" for="SkipButtonIntroText"> Skip intro button text </label>
<input id="SkipButtonIntroText" type="text" is="emby-input" /> <input id="SkipButtonIntroText" type="text" is="emby-input" />
@ -390,20 +407,16 @@
<fieldset class="verticalSection-extrabottompadding"> <fieldset class="verticalSection-extrabottompadding">
<legend>Advanced</legend> <legend>Advanced</legend>
<details id="support">
<summary>Support Bundle Info</summary>
<textarea id="supportBundle" rows="20" cols="75" readonly></textarea>
</details>
<details id="visualizer"> <details id="visualizer">
<summary>Manage Timestamps & Fingerprints</summary> <summary>Manage Timestamps & Fingerprints</summary>
<br /> <br />
<label class="inputLabel" for="troubleshooterShow">Select TV Series to manage</label> <label class="inputLabel" for="troubleshooterShow">Select TV series / movie to manage</label>
<select is="emby-select" id="troubleshooterShow" class="emby-select-withcolor emby-select"></select> <select is="emby-select" id="troubleshooterShow" class="emby-select-withcolor emby-select"></select>
<label class="inputLabel" for="troubleshooterSeason">Select Season to manage</label> <div id="seasonSelection">
<select is="emby-select" id="troubleshooterSeason" 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>
</div>
<br /> <br />
<div id="ignorelistSection" style="display: none"> <div id="ignorelistSection" style="display: none">
@ -426,16 +439,18 @@
</div> </div>
<br /> <br />
<button id="saveIgnoreListSeries" class="raised button-submit block emby-button">Apply to series</button> <button is="emby-button" id="saveIgnoreListSeries" class="raised button-submit block emby-button">Apply to series / movie</button>
<button id="saveIgnoreListSeason" class="raised button-submit block emby-button" style="display: none">Apply to season</button> <button is="emby-button" id="saveIgnoreListSeason" class="raised button-submit block emby-button" style="display: none">Apply to season</button>
</div> </div>
<br /> <br />
<label class="inputLabel" for="troubleshooterEpisode1">Select the first episode</label> <div id="episodeSelection">
<select is="emby-select" id="troubleshooterEpisode1" class="emby-select-withcolor emby-select"></select> <label class="inputLabel" for="troubleshooterEpisode1">Select the first episode</label>
<label class="inputLabel" for="troubleshooterEpisode1">Select the second episode</label> <select is="emby-select" id="troubleshooterEpisode1" class="emby-select-withcolor emby-select"></select>
<select is="emby-select" id="troubleshooterEpisode2" class="emby-select-withcolor emby-select"></select> <label class="inputLabel" for="troubleshooterEpisode1">Select the second episode</label>
<br /> <select is="emby-select" id="troubleshooterEpisode2" class="emby-select-withcolor emby-select"></select>
<br />
</div>
<div id="timestampEditor" style="display: none"> <div id="timestampEditor" style="display: none">
<h3 style="margin: 0">Introduction timestamp editor</h3> <h3 style="margin: 0">Introduction timestamp editor</h3>
@ -467,114 +482,138 @@
</div> </div>
</div> </div>
<br /> <br />
<h4 style="margin: 0" id="editRightEpisodeTitle"></h4> <div id="rightEpisodeEditor">
<br /> <h4 style="margin: 0" id="editRightEpisodeTitle"></h4>
<div class="inlineForm"> <br />
<div class="inputContainer"> <div class="inlineForm">
<label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label> <div class="inputContainer">
<input type="text" id="editRightIntroEpisodeStartDisplay" class="emby-input custom-time-input" readonly /> <label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label>
<input type="number" id="editRightIntroEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" /> <input type="text" id="editRightIntroEpisodeStartDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editRightIntroEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label>
<input type="text" id="editRightIntroEpisodeEndDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editRightIntroEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
</div> </div>
<div class="inputContainer"> <div class="inlineForm">
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label> <div class="inputContainer">
<input type="text" id="editRightIntroEpisodeEndDisplay" class="emby-input custom-time-input" readonly /> <label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
<input type="number" id="editRightIntroEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" /> <input type="text" id="editRightCreditEpisodeStartDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editRightCreditEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="creditsEnd">Credits End</label>
<input type="text" id="editRightCreditEpisodeEndDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editRightCreditEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
</div> </div>
<br />
</div> </div>
<div class="inlineForm"> <button is="emby-button" id="btnUpdateTimestamps" class="raised button-submit block emby-button" type="button">Update timestamps</button>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
<input type="text" id="editRightCreditEpisodeStartDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editRightCreditEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="creditsEnd">Credits End</label>
<input type="text" id="editRightCreditEpisodeEndDisplay" class="emby-input custom-time-input" readonly />
<input type="number" id="editRightCreditEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
</div>
</div>
<br />
<button is="emby-select" id="btnUpdateTimestamps" class="raised button-submit block emby-button" type="button">Update timestamps</button>
<br /> <br />
</div> </div>
<div id="timestampErrorDiv" style="display: none"> <div id="timestampErrorDiv" style="display: none">
<br />
<textarea id="timestampError" rows="2" cols="75" readonly></textarea> <textarea id="timestampError" rows="2" cols="75" readonly></textarea>
<br /> <br />
<br /> <br />
</div> </div>
<h3>Fingerprint Visualizer</h3> <div id="fingerprintVisualizer" style="display: none">
<p>
Interactively compare the audio fingerprints of two episodes. <br />
The blue and red bar to the right of the fingerprint diff turns blue when the corresponding fingerprint points are at least 80% similar.
</p>
<table>
<thead>
<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.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.1238 seconds. Holding control will shift the episode by 10 seconds.</td>
</tr>
<tr>
<td>Right arrow</td>
<td>Advance to the next pair of episodes.</td>
</tr>
<tr>
<td>Left arrow</td>
<td>Go back to the previous pair of episodes.</td>
</tr>
</tbody>
</table>
<br />
<span>Shift amount:</span> <h3>Fingerprint Visualizer</h3>
<input type="number" min="-3000" max="3000" value="0" id="offset" /> <p>
<br /> Interactively compare the audio fingerprints of two episodes. <br />
<span id="suggestedShifts">Suggested shifts:</span> The blue and red bar to the right of the fingerprint diff turns blue when the corresponding fingerprint points are at least 80% similar.
<br /> </p>
<br /> <table>
<thead>
<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.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.1238 seconds. Holding control will shift the episode by 10 seconds.</td>
</tr>
<tr>
<td>Right arrow</td>
<td>Advance to the next pair of episodes.</td>
</tr>
<tr>
<td>Left arrow</td>
<td>Go back to the previous pair of episodes.</td>
</tr>
</tbody>
</table>
<br />
<canvas id="troubleshooter" style="display: none"></canvas> <span>Shift amount:</span>
<span id="timestampContainer"> <input type="number" min="-3000" max="3000" value="0" id="offset" />
<span id="timestamps"></span> <br /> <br />
<span id="intros"></span> <span id="suggestedShifts">
</span> <span>Suggested shifts: </span>
<br /> </span>
<br /> <br />
<br />
<div id="eraseSeasonContainer" style="display: none"> <canvas id="troubleshooter" style="display: none"></canvas>
<button id="btnEraseSeasonTimestamps" type="button">Erase all timestamps for this season</button> <span id="timestampContainer">
<span id="timestamps"></span>
<input type="checkbox" id="eraseSeasonCacheCheckbox" style="margin-left: 10px" /> <br />
<label for="eraseSeasonCacheCheckbox" style="margin-left: 5px">Erase cached fingerprint files</label> <span id="intros"></span>
</span>
<br />
</div> </div>
<br /> <br />
<button id="btnEraseIntroTimestamps">Erase all introduction timestamps (globally)</button> <div id="eraseSeasonContainer" style="display: none">
<button is="emby-button" id="btnEraseSeasonTimestamps" class="button-submit emby-button" type="button">Erase all timestamps for this season</button>
<input type="checkbox" id="eraseSeasonCacheCheckbox" style="margin-left: 10px" />
<label for="eraseSeasonCacheCheckbox" style="margin-left: 5px">Erase cached fingerprint files</label>
<br />
</div>
<div id="eraseMovieContainer" style="display: none">
<button is="emby-button" id="btnEraseMovieTimestamps" class="button-submit emby-button" type="button">Erase all timestamps for this movie</button>
<input type="checkbox" id="eraseMovieCacheCheckbox" style="margin-left: 10px" />
<label for="eraseMovieCacheCheckbox" style="margin-left: 5px">Erase cached fingerprint files</label>
<br />
</div>
<button is="emby-button" class="button-submit emby-button" id="btnEraseIntroTimestamps">Erase all introduction timestamps (globally)</button>
<br /> <br />
<button id="btnEraseCreditTimestamps">Erase all end credits timestamps (globally)</button> <button is="emby-button" class="button-submit emby-button" id="btnEraseCreditTimestamps">Erase all end credits timestamps (globally)</button>
<br />
<br /> <br />
<input type="checkbox" id="eraseModeCacheCheckbox" style="margin-left: 10px" /> <input type="checkbox" id="eraseModeCacheCheckbox" style="margin-left: 10px" />
<label for="eraseModeCacheCheckbox" style="margin-left: 5px">Erase cached fingerprint files</label> <label for="eraseModeCacheCheckbox" style="margin-left: 5px">Erase cached fingerprint files</label>
<br />
<br />
</details>
<details id="support">
<summary>Support Bundle Info</summary>
<textarea id="supportBundle" rows="20" cols="75" readonly></textarea>
</details> </details>
<details id="storage"> <details id="storage">
<br /> <br />
<summary>Storage Usage</summary> <summary>Storage Usage</summary>
<div class="fieldDescription">See how much space each library uses.</div> <div class="fieldDescription">See how much space each library uses.</div>
<textarea id="storage" rows="20" cols="75" readonly></textarea> <textarea id="storageText" rows="20" cols="75" readonly></textarea>
</details> </details>
</fieldset> </fieldset>
</form> </form>
@ -616,6 +655,7 @@
"MaximumIntroDuration", "MaximumIntroDuration",
"MinimumCreditsDuration", "MinimumCreditsDuration",
"MaximumCreditsDuration", "MaximumCreditsDuration",
"MaximumMovieCreditsDuration",
"EdlAction", "EdlAction",
"ProcessPriority", "ProcessPriority",
"ProcessThreads", "ProcessThreads",
@ -635,7 +675,7 @@
"AutoSkipCreditsNotificationText", "AutoSkipCreditsNotificationText",
]; ];
var booleanConfigurationFields = ["AutoDetectIntros", "AutoDetectCredits", "AnalyzeSeasonZero", "SelectAllLibraries", "RegenerateEdlFiles", "CacheFingerprints", "AutoSkip", "AutoSkipCredits", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonVisible"]; var booleanConfigurationFields = ["AutoDetectIntros", "AutoDetectCredits", "AnalyzeMovies", "AnalyzeSeasonZero", "SelectAllLibraries", "RegenerateEdlFiles", "CacheFingerprints", "AutoSkip", "AutoSkipCredits", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonVisible"];
// visualizer elements // visualizer elements
var ignorelistSection = document.querySelector("div#ignorelistSection"); var ignorelistSection = document.querySelector("div#ignorelistSection");
@ -645,20 +685,27 @@
var saveIgnoreListSeriesButton = ignorelistSection.querySelector("button#saveIgnoreListSeries"); var saveIgnoreListSeriesButton = ignorelistSection.querySelector("button#saveIgnoreListSeries");
var canvas = document.querySelector("canvas#troubleshooter"); var canvas = document.querySelector("canvas#troubleshooter");
var selectShow = document.querySelector("select#troubleshooterShow"); var selectShow = document.querySelector("select#troubleshooterShow");
var seasonSelection = document.getElementById("seasonSelection");
var selectSeason = document.querySelector("select#troubleshooterSeason"); var selectSeason = document.querySelector("select#troubleshooterSeason");
var episodeSelection = document.getElementById("episodeSelection");
var selectEpisode1 = document.querySelector("select#troubleshooterEpisode1"); var selectEpisode1 = document.querySelector("select#troubleshooterEpisode1");
var selectEpisode2 = document.querySelector("select#troubleshooterEpisode2"); var selectEpisode2 = document.querySelector("select#troubleshooterEpisode2");
var txtOffset = document.querySelector("input#offset"); var txtOffset = document.querySelector("input#offset");
var txtSuggested = document.querySelector("span#suggestedShifts"); var txtSuggested = document.querySelector("span#suggestedShifts");
var btnSeasonEraseTimestamps = document.querySelector("button#btnEraseSeasonTimestamps"); var btnSeasonEraseTimestamps = document.querySelector("button#btnEraseSeasonTimestamps");
var eraseSeasonContainer = document.getElementById("eraseSeasonContainer"); var eraseSeasonContainer = document.getElementById("eraseSeasonContainer");
var btnMovieEraseTimestamps = document.querySelector("button#btnEraseMovieTimestamps");
var eraseMovieContainer = document.getElementById("eraseMovieContainer");
var timestampError = document.querySelector("textarea#timestampError"); var timestampError = document.querySelector("textarea#timestampError");
var timestampEditor = document.querySelector("#timestampEditor"); var timestampEditor = document.querySelector("#timestampEditor");
var rightEpisodeEditor = document.getElementById("rightEpisodeEditor");
var btnUpdateTimestamps = document.querySelector("button#btnUpdateTimestamps"); var btnUpdateTimestamps = document.querySelector("button#btnUpdateTimestamps");
var timeContainer = document.querySelector("span#timestampContainer"); var timeContainer = document.querySelector("span#timestampContainer");
var fingerprintVisualizer = document.getElementById("fingerprintVisualizer");
var windowHashInterval = 0; var windowHashInterval = 0;
var analyzeMovies = document.getElementById("AnalyzeMovies");
var autoSkip = document.querySelector("input#AutoSkip"); var autoSkip = document.querySelector("input#AutoSkip");
var skipButtonVisible = document.getElementById("SkipButtonVisible"); var skipButtonVisible = document.getElementById("SkipButtonVisible");
var skipButtonVisibleLabel = document.getElementById("SkipButtonVisibleLabel"); var skipButtonVisibleLabel = document.getElementById("SkipButtonVisibleLabel");
@ -669,6 +716,7 @@
var secondsOfIntroStartToPlay = document.querySelector("div#divSecondsOfIntroStartToPlay"); var secondsOfIntroStartToPlay = document.querySelector("div#divSecondsOfIntroStartToPlay");
var autoSkipClientList = document.getElementById("AutoSkipClientList"); var autoSkipClientList = document.getElementById("AutoSkipClientList");
var secondsOfCreditsStartToPlay = document.querySelector("div#divSecondsOfCreditsStartToPlay"); var secondsOfCreditsStartToPlay = document.querySelector("div#divSecondsOfCreditsStartToPlay");
var movieCreditsDuration = document.getElementById("movieCreditsDuration");
var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText"); var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText");
var autoSkipCredits = document.querySelector("input#AutoSkipCredits"); var autoSkipCredits = document.querySelector("input#AutoSkipCredits");
var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText"); var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText");
@ -692,15 +740,15 @@
} else if (autoSkip.checked) { } else if (autoSkip.checked) {
autoSkipClientList.style.display = "unset"; autoSkipClientList.style.display = "unset";
autoSkipClientList.style.width = "100%"; autoSkipClientList.style.width = "100%";
skipButtonVisibleLabel.textContent = "Show skip credit button"; skipButtonVisibleLabel.textContent = "Show Skip Credit Button";
} else if (autoSkipCredits.checked) { } else if (autoSkipCredits.checked) {
autoSkipClientList.style.display = "unset"; autoSkipClientList.style.display = "unset";
autoSkipClientList.style.width = "100%"; autoSkipClientList.style.width = "100%";
skipButtonVisibleLabel.textContent = "Show skip intro button"; skipButtonVisibleLabel.textContent = "Show Skip Intro Button";
} else { } else {
autoSkipClientList.style.display = "unset"; autoSkipClientList.style.display = "unset";
autoSkipClientList.style.width = "100%"; autoSkipClientList.style.width = "100%";
skipButtonVisibleLabel.textContent = "Show skip intro / credit button"; skipButtonVisibleLabel.textContent = "Show All Skip Buttons";
} }
skipButtonVisibleChanged(); skipButtonVisibleChanged();
} }
@ -780,7 +828,7 @@
async function populateLibraries() { async function populateLibraries() {
const response = await getJson("Library/VirtualFolders"); const response = await getJson("Library/VirtualFolders");
const tvLibraries = response.filter((item) => item.CollectionType === undefined || item.CollectionType === "tvshows"); const tvLibraries = response.filter((item) => item.CollectionType === undefined || item.CollectionType === "tvshows" || item.CollectionType === "movies");
const libraryNames = tvLibraries.map((lib) => lib.Name || "Unnamed Library"); const libraryNames = tvLibraries.map((lib) => lib.Name || "Unnamed Library");
generateCheckboxList(libraryNames, "libraryCheckboxes", "SelectedLibraries"); generateCheckboxList(libraryNames, "libraryCheckboxes", "SelectedLibraries");
} }
@ -802,6 +850,16 @@
persistSkip.addEventListener("change", persistSkipChanged); persistSkip.addEventListener("change", persistSkipChanged);
async function analyzeMoviesChanged() {
if (analyzeMovies.checked) {
movieCreditsDuration.style.display = "unset";
} else {
movieCreditsDuration.style.display = "none";
}
}
analyzeMovies.addEventListener("change", analyzeMoviesChanged);
// when the fingerprint visualizer opens, populate show names // when the fingerprint visualizer opens, populate show names
async function visualizerToggled() { async function visualizerToggled() {
if (!visualizer.open) { if (!visualizer.open) {
@ -887,14 +945,17 @@
const bundleText = await bundle.text(); const bundleText = await bundle.text();
// Display it to the user // Display it to the user
const ta = document.querySelector("textarea#storage"); const ta = document.querySelector("textarea#storageText");
ta.value = bundleText; ta.value = bundleText;
} }
// show changed, populate seasons // show changed, populate seasons
async function showChanged() { async function showChanged() {
seasonSelection.style.display = "unset";
clearSelect(selectSeason); clearSelect(selectSeason);
eraseSeasonContainer.style.display = "none"; eraseSeasonContainer.style.display = "none";
eraseMovieContainer.style.display = "none";
episodeSelection.style.display = "unset";
clearSelect(selectEpisode1); clearSelect(selectEpisode1);
clearSelect(selectEpisode2); clearSelect(selectEpisode2);
@ -907,6 +968,13 @@
saveIgnoreListSeasonButton.style.display = "none"; saveIgnoreListSeasonButton.style.display = "none";
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
if (shows[selectShow.value].IsMovie) {
movieLoaded();
return;
}
saveIgnoreListSeriesButton.textContent = "Apply to series";
// add all seasons from this show to the season select // add all seasons from this show to the season select
for (const season in shows[selectShow.value].Seasons) { for (const season in shows[selectShow.value].Seasons) {
addItem(selectSeason, "Season " + shows[selectShow.value].Seasons[season], season); addItem(selectSeason, "Season " + shows[selectShow.value].Seasons[season], season);
@ -957,20 +1025,23 @@
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
timestampError.value = ""; timestampError.value = "";
fingerprintVisualizer.style.display = "unset";
canvas.style.display = "none"; canvas.style.display = "none";
lhs = await getJson("Intros/Episode/" + selectEpisode1.value + "/Chromaprint"); lhs = await getJson("Intros/Episode/" + selectEpisode1.value + "/Chromaprint");
if (lhs === undefined) { if (lhs === undefined) {
timestampError.value += "Error: " + selectEpisode1.value + " fingerprints failed!\n"; timestampError.value += "Error: " + selectEpisode1.value + " fingerprints failed!\n";
} else if (lhs === null) { } else if (lhs === null) {
timestampError.value += "Error: " + selectEpisode1.value + " fingerprints missing!\n"; timestampError.value += selectEpisode1.value + " fingerprints missing or incomplete.\n";
} }
rightEpisodeEditor.style.display = "unset";
rhs = await getJson("Intros/Episode/" + selectEpisode2.value + "/Chromaprint"); rhs = await getJson("Intros/Episode/" + selectEpisode2.value + "/Chromaprint");
if (rhs === undefined) { if (rhs === undefined) {
timestampError.value += "Error: " + selectEpisode2.value + " fingerprints failed!"; timestampError.value += "Error: " + selectEpisode2.value + " fingerprints failed!";
} else if (rhs === null) { } else if (rhs === null) {
timestampError.value += "Error: " + selectEpisode2.value + " fingerprints missing!\n"; timestampError.value += selectEpisode2.value + " fingerprints missing or incomplete.\n";
} }
if (timestampError.value == "") { if (timestampError.value == "") {
@ -988,6 +1059,58 @@
updateTimestampEditor(); updateTimestampEditor();
} }
async function movieLoaded() {
Dashboard.showLoadingMsg();
saveIgnoreListSeriesButton.textContent = "Apply to movie";
seasonSelection.style.display = "none";
episodeSelection.style.display = "none";
eraseMovieContainer.style.display = "unset";
timestampError.value = "";
fingerprintVisualizer.style.display = "none";
lhs = await getJson("Intros/Episode/" + selectShow.value + "/Chromaprint");
if (lhs === undefined) {
timestampError.value += "Error: " + selectShow.value + " fingerprints failed!\n";
} else if (lhs === null) {
timestampError.value += selectShow.value + " fingerprints missing or incomplete.\n";
}
rightEpisodeEditor.style.display = "none";
if (timestampError.value == "") {
timestampErrorDiv.style.display = "none";
} else {
timestampErrorDiv.style.display = "unset";
}
Dashboard.hideLoadingMsg();
txtOffset.value = "0";
// Try to get the timestamps of each intro, falling back a default value of zero if no intro was found
const leftEpisodeJson = await getJson("Episode/" + selectShow.value + "/Timestamps");
// Update the editor for the movie
timestampEditor.style.display = "unset";
document.querySelector("#editLeftEpisodeTitle").textContent = selectShow.value;
document.querySelector("#editLeftIntroEpisodeStartEdit").value = leftEpisodeJson.Introduction.Start;
document.querySelector("#editLeftIntroEpisodeEndEdit").value = leftEpisodeJson.Introduction.End;
document.querySelector("#editLeftCreditEpisodeStartEdit").value = leftEpisodeJson.Credits.Start;
document.querySelector("#editLeftCreditEpisodeEndEdit").value = leftEpisodeJson.Credits.End;
// Update display inputs
const inputs = document.querySelectorAll('#timestampEditor input[type="number"]');
inputs.forEach((input) => {
const displayInput = document.getElementById(input.id.replace("Edit", "Display"));
displayInput.value = formatTime(parseFloat(input.value) || 0);
});
setupTimeInputs();
}
function setupTimeInputs() { function setupTimeInputs() {
const timestampEditor = document.getElementById("timestampEditor"); const timestampEditor = document.getElementById("timestampEditor");
timestampEditor.querySelectorAll(".inputContainer").forEach((container) => { timestampEditor.querySelectorAll(".inputContainer").forEach((container) => {
@ -1215,8 +1338,7 @@
} }
if (config["SkipButtonWarning"]) { if (config["SkipButtonWarning"]) {
document.getElementById("SkipButtonContainer").style.display = "none"; document.getElementById("warningMessage").style.display = "unset";
document.getElementById("PersistContainer").style.display = "none";
} else { } else {
document.getElementById("warningMessage").style.display = "none"; document.getElementById("warningMessage").style.display = "none";
} }
@ -1285,6 +1407,22 @@
document.getElementById("eraseSeasonCacheCheckbox").checked = false; document.getElementById("eraseSeasonCacheCheckbox").checked = false;
}); });
}); });
btnMovieEraseTimestamps.addEventListener("click", () => {
Dashboard.confirm("Are you sure you want to erase all timestamps for this movie?", "Confirm timestamp erasure", (result) => {
if (!result) {
return;
}
const show = selectShow.value;
const eraseCacheChecked = document.getElementById("eraseMovieCacheCheckbox").checked;
const url = "Intros/Show/" + encodeURIComponent(show);
fetchWithAuth(url + "?eraseCache=" + eraseCacheChecked, "DELETE", null);
Dashboard.alert("Erased timestamps for " + show);
document.getElementById("eraseMovieCacheCheckbox").checked = false;
});
});
saveIgnoreListSeasonButton.addEventListener("click", () => { saveIgnoreListSeasonButton.addEventListener("click", () => {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();

View File

@ -0,0 +1,591 @@
const introSkipper = {
originalFetch: window.fetch.bind(window),
d: (msg) => console.debug("[intro skipper] ", msg),
setup() {
const self = this;
this.initializeState();
this.initializeObserver();
this.currentOption =
localStorage.getItem("introskipperOption") || "Show Button";
window.fetch = this.fetchWrapper.bind(this);
document.addEventListener("viewshow", this.viewShow.bind(this));
this.videoPositionChanged = this.videoPositionChanged.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.d("Registered hooks");
},
initializeState() {
Object.assign(this, {
allowEnter: true,
skipSegments: {},
videoPlayer: null,
skipButton: null,
osdElement: null,
skipperData: null,
currentEpisodeId: null,
injectMetadata: false,
});
},
initializeObserver() {
this.observer = new MutationObserver((mutations) => {
const actionSheet =
mutations[mutations.length - 1].target.querySelector(".actionSheet");
if (
actionSheet &&
!actionSheet.querySelector(`[data-id="${"introskipperMenu"}"]`)
)
this.injectIntroSkipperOptions(actionSheet);
});
},
fetchWrapper(resource, options) {
const response = this.originalFetch(resource, options);
const url = new URL(resource);
if (url.pathname.includes("/PlaybackInfo")) {
this.processPlaybackInfo(url.pathname);
}
else if (this.injectMetadata && url.pathname.includes("/MetadataEditor")) {
this.processMetadata(url.pathname);
}
return response;
},
async processPlaybackInfo(url) {
const id = this.extractId(url);
if (id) {
try {
this.skipSegments = await this.secureFetch(
`Episode/${id}/IntroSkipperSegments`,
);
} catch (error) {
this.d(`Error fetching skip segments: ${error.message}`);
}
}
},
async processMetadata(url) {
const id = this.extractId(url);
if (id) {
try {
this.skipperData = await this.secureFetch(`Episode/${id}/Timestamps`);
if (this.skipperData) {
this.currentEpisodeId = id;
requestAnimationFrame(() => {
const metadataFormFields = document.querySelector(
".metadataFormFields",
);
metadataFormFields && this.injectSkipperFields(metadataFormFields);
});
}
} catch (e) {
console.error("Error processing", e);
}
}
},
extractId(searchString) {
const startIndex = searchString.indexOf("Items/") + 6;
const endIndex = searchString.indexOf("/", startIndex);
return endIndex !== -1
? searchString.substring(startIndex, endIndex)
: searchString.substring(startIndex);
},
/**
* Event handler that runs whenever the current view changes.
* Used to detect the start of video playback.
*/
viewShow() {
const location = window.location.hash;
this.d(`Location changed to ${location}`);
this.allowEnter = true;
this.injectMetadata = /#\/(tv|details|home|search)/.test(location);
if (location === "#/video") {
this.injectCss();
this.injectButton();
this.videoPlayer = document.querySelector("video");
if (this.videoPlayer) {
this.d("Hooking video timeupdate");
this.videoPlayer.addEventListener(
"timeupdate",
this.videoPositionChanged,
);
this.osdElement = document.querySelector("div.videoOsdBottom");
this.observer.observe(document.body, {
childList: true,
subtree: false,
});
}
} else {
this.observer.disconnect();
}
},
/**
* Injects the CSS used by the skip intro button.
* Calling this function is a no-op if the CSS has already been injected.
*/
injectCss() {
if (document.querySelector("style#introSkipperCss")) {
this.d("CSS already added");
return;
}
this.d("Adding CSS");
const styleElement = document.createElement("style");
styleElement.id = "introSkipperCss";
styleElement.textContent = `
:root {
--rounding: 4px;
--accent: 0, 164, 220;
}
#skipIntro.upNextContainer {
width: unset;
margin: unset;
}
#skipIntro {
position: absolute;
bottom: 7.5em;
right: 5em;
background-color: transparent;
}
#skipIntro .emby-button {
color: #ffffff;
font-size: 110%;
background: rgba(0, 0, 0, 0.7);
border-radius: var(--rounding);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
transition: opacity 0.3s cubic-bezier(0.4,0,0.2,1),
transform 0.3s cubic-bezier(0.4,0,0.2,1),
background-color 0.2s ease-out,
box-shadow 0.2s ease-out;
opacity: 0;
transform: translateY(50%);
}
#skipIntro.show .emby-button {
opacity: 1;
transform: translateY(0);
}
#skipIntro .emby-button:hover {
background: rgb(var(--accent));
box-shadow: 0 0 8px rgba(var(--accent), 0.6);
filter: brightness(1.2);
}
#skipIntro .emby-button:focus {
background: rgb(var(--accent));
box-shadow: 0 0 8px rgba(var(--accent), 0.6);
}
#btnSkipSegmentText {
letter-spacing: 0.5px;
padding: 0 5px 0 5px;
}
`;
document.querySelector("head").appendChild(styleElement);
},
/**
* Inject the skip intro button into the video player.
* Calling this function is a no-op if the CSS has already been injected.
*/
async injectButton() {
// Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
const preExistingButton = document.querySelector("div.skipIntro");
if (preExistingButton) {
preExistingButton.style.display = "none";
}
if (document.querySelector(".btnSkipIntro.injected")) {
this.d("Button already added");
this.skipButton = document.querySelector("#skipIntro");
return;
}
const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
if (!config.SkipButtonVisible) {
this.d("Not adding button: not visible");
return;
}
this.d("Adding button");
this.skipButton = document.createElement("div");
this.skipButton.id = "skipIntro";
this.skipButton.classList.add("hide", "upNextContainer");
this.skipButton.addEventListener("click", this.doSkip.bind(this));
this.skipButton.addEventListener("keydown", this.eventHandler.bind(this));
this.skipButton.innerHTML = `
<button is="emby-button" type="button" class="btnSkipIntro injected">
<span id="btnSkipSegmentText"></span>
<span class="material-icons skip_next"></span>
</button>
`;
this.skipButton.dataset.Introduction = config.SkipButtonIntroText;
this.skipButton.dataset.Credits = config.SkipButtonEndCreditsText;
const controls = document.querySelector("div#videoOsdPage");
controls.appendChild(this.skipButton);
},
/** Tests if the OSD controls are visible. */
osdVisible() {
return this.osdElement
? !this.osdElement.classList.contains("hide")
: false;
},
/** Get the currently playing skippable segment. */
getCurrentSegment(position) {
for (const key in this.skipSegments) {
const segment = this.skipSegments[key];
if (
(position > segment.ShowSkipPromptAt &&
position < segment.HideSkipPromptAt) ||
(this.osdVisible() &&
position > segment.IntroStart &&
position < segment.IntroEnd - 3)
) {
return { ...segment, SegmentType: key };
}
}
return { SegmentType: "None" };
},
overrideBlur(button) {
if (!button.originalBlur) {
button.originalBlur = button.blur;
button.blur = function () {
if (!this.contains(document.activeElement)) {
this.originalBlur();
}
};
}
},
/** Playback position changed, check if the skip button needs to be displayed. */
videoPositionChanged() {
if (!this.skipButton || !this.allowEnter) return;
const { SegmentType: segmentType } = this.getCurrentSegment(this.videoPlayer.currentTime);
if (
segmentType === "None" ||
this.currentOption === "Off"
) {
this.hideSkipButton();
return;
}
if (
this.currentOption === "Automatically Skip" ||
(this.currentOption === "Button w/ auto PiP" &&
document.pictureInPictureElement)
) {
this.doSkip();
return;
}
const button = this.skipButton.querySelector(".emby-button");
this.skipButton.querySelector("#btnSkipSegmentText").textContent =
this.skipButton.dataset[segmentType];
if (!this.skipButton.classList.contains("hide")) {
if (!this.osdVisible() && !button.contains(document.activeElement)) {
button.focus();
}
return;
}
requestAnimationFrame(() => {
this.skipButton.classList.remove("hide");
requestAnimationFrame(() => {
this.skipButton.classList.add("show");
this.overrideBlur(button);
button.focus();
});
});
},
hideSkipButton() {
if (this.skipButton.classList.contains("show")) {
this.skipButton.classList.remove("show");
const button = this.skipButton.querySelector(".emby-button");
button.addEventListener(
"transitionend",
() => {
this.skipButton.classList.add("hide");
if (this.osdVisible()) {
this.osdElement.querySelector("button.btnPause").focus();
} else {
button.originalBlur();
}
},
{ once: true },
);
}
},
/** Seeks to the end of the intro. */
doSkip() {
if (!this.allowEnter) return;
const segment = this.getCurrentSegment(this.videoPlayer.currentTime);
if (segment.SegmentType === "None") {
console.warn("[intro skipper] doSkip() called without an active segment");
return;
}
this.d(`Skipping ${segment.SegmentType}`);
const seekedHandler = () => {
this.videoPlayer.removeEventListener("seeked", seekedHandler);
setTimeout(() => {
this.allowEnter = true;
}, 700);
};
this.videoPlayer.addEventListener("seeked", seekedHandler);
this.videoPlayer.currentTime = segment.IntroEnd;
this.hideSkipButton();
},
createButton(ref, id, innerHTML, clickHandler) {
const button = ref.cloneNode(true);
button.setAttribute("data-id", id);
button.innerHTML = innerHTML;
button.addEventListener("click", clickHandler);
return button;
},
closeSubmenu(fullscreen) {
document.querySelector(".dialogContainer").remove();
document.querySelector(".dialogBackdrop").remove();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Control" }));
if (!fullscreen) return;
document.removeEventListener("keydown", this.handleEscapeKey);
document.querySelector(".btnVideoOsdSettings").focus();
},
openSubmenu(ref, menu) {
const options = [
"Show Button",
"Button w/ auto PiP",
"Automatically Skip",
"Off",
];
const submenu = menu.cloneNode(true);
const scroller = submenu.querySelector(".actionSheetScroller");
scroller.innerHTML = "";
for (const option of options) {
if (option !== "Button w/ auto PiP" || document.pictureInPictureEnabled) {
const button = this.createButton(
ref,
`introskipper-${option.toLowerCase().replace(" ", "-")}`,
`<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons check" aria-hidden="true" style="visibility:${option === this.currentOption ? "visible" : "hidden"};"></span><div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">${option}</div></div>`,
() => this.selectOption(option),
);
scroller.appendChild(button);
}
}
const backdrop = document.createElement("div");
backdrop.className = "dialogBackdrop dialogBackdropOpened";
document.body.append(backdrop, submenu);
const actionSheet = submenu.querySelector(".actionSheet");
if (actionSheet.classList.contains("actionsheet-not-fullscreen")) {
this.adjustPosition(
actionSheet,
document.querySelector(".btnVideoOsdSettings"),
);
submenu.addEventListener("click", () => this.closeSubmenu(false));
} else {
submenu
.querySelector(".btnCloseActionSheet")
.addEventListener("click", () => this.closeSubmenu(true));
scroller.addEventListener("click", () => this.closeSubmenu(true));
document.addEventListener("keydown", this.handleEscapeKey);
setTimeout(() => scroller.firstElementChild.focus(), 240);
}
},
selectOption(option) {
this.currentOption = option;
localStorage.setItem("introskipperOption", option);
this.d(`Introskipper option selected and saved: ${option}`);
},
isAutoSkipLocked(config) {
const isAutoSkip = config.AutoSkip && config.AutoSkipCredits;
const isAutoSkipClient = new Set(config.ClientList.split(",")).has(
ApiClient.appName(),
);
return isAutoSkip || (config.SkipButtonVisible && isAutoSkipClient);
},
async injectIntroSkipperOptions(actionSheet) {
if (!this.skipButton) return;
const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
if (this.isAutoSkipLocked(config)) {
this.d("Auto skip enforced by server");
return;
}
const statsButton = actionSheet.querySelector('[data-id="stats"]');
if (!statsButton) return;
const menuItem = this.createButton(
statsButton,
"introskipperMenu",
`<div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">Intro Skipper</div></div><div class="listItemAside actionSheetItemAsideText">${this.currentOption}</div>`,
() =>
this.openSubmenu(statsButton, actionSheet.closest(".dialogContainer")),
);
const originalWidth = actionSheet.offsetWidth;
statsButton.before(menuItem);
if (actionSheet.classList.contains("actionsheet-not-fullscreen"))
this.adjustPosition(actionSheet, menuItem, originalWidth);
},
adjustPosition(element, reference, originalWidth) {
if (originalWidth) {
const currentTop = Number.parseInt(element.style.top, 10) || 0;
element.style.top = `${currentTop - reference.offsetHeight}px`;
const newWidth = Math.max(reference.offsetWidth - originalWidth, 0);
const originalLeft = Number.parseInt(element.style.left, 10) || 0;
element.style.left = `${originalLeft - newWidth / 2}px`;
} else {
const rect = reference.getBoundingClientRect();
element.style.left = `${Math.min(rect.left - (element.offsetWidth - rect.width) / 2, window.innerWidth - element.offsetWidth - 10)}px`;
element.style.top = `${rect.top - element.offsetHeight + rect.height}px`;
}
},
injectSkipperFields(metadataFormFields) {
const skipperFields = document.createElement("div");
skipperFields.className = "detailSection introskipperSection";
skipperFields.innerHTML = `
<h2>Intro Skipper</h2>
<div class="inlineForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label>
<input type="text" id="introStartDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="introStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label>
<input type="text" id="introEndDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="introEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
</div>
<div class="inlineForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
<input type="text" id="creditsStartDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="creditsStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="creditsEnd">Credits End</label>
<input type="text" id="creditsEndDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="creditsEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
</div>
`;
metadataFormFields
.querySelector("#metadataSettingsCollapsible")
.insertAdjacentElement("afterend", skipperFields);
this.attachSaveListener(metadataFormFields);
this.updateSkipperFields(skipperFields);
this.setTimeInputs(skipperFields);
},
updateSkipperFields(skipperFields) {
const { Introduction = {}, Credits = {} } = this.skipperData;
skipperFields.querySelector("#introStartEdit").value =
Introduction.Start || 0;
skipperFields.querySelector("#introEndEdit").value = Introduction.End || 0;
skipperFields.querySelector("#creditsStartEdit").value = Credits.Start || 0;
skipperFields.querySelector("#creditsEndEdit").value = Credits.End || 0;
},
attachSaveListener(metadataFormFields) {
const saveButton = metadataFormFields.querySelector(
".formDialogFooter .btnSave",
);
if (saveButton) {
saveButton.addEventListener("click", this.saveSkipperData.bind(this));
} else {
console.error("Save button not found");
}
},
setTimeInputs(skipperFields) {
const inputContainers = skipperFields.querySelectorAll(".inputContainer");
for (const container of inputContainers) {
const displayInput = container.querySelector('[id$="Display"]');
const editInput = container.querySelector('[id$="Edit"]');
displayInput.addEventListener("pointerdown", (e) => {
e.preventDefault();
this.switchToEdit(displayInput, editInput);
});
editInput.addEventListener("blur", () =>
this.switchToDisplay(displayInput, editInput),
);
displayInput.value = this.formatTime(
Number.parseFloat(editInput.value) || 0,
);
}
},
formatTime(totalSeconds) {
if (!totalSeconds) return "0 seconds";
const totalRoundedSeconds = Math.round(totalSeconds);
const hours = Math.floor(totalRoundedSeconds / 3600);
const minutes = Math.floor((totalRoundedSeconds % 3600) / 60);
const seconds = totalRoundedSeconds % 60;
const result = [];
if (hours) result.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
if (minutes) result.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
if (seconds || !result.length)
result.push(`${seconds} second${seconds !== 1 ? "s" : ""}`);
return result.join(" ");
},
switchToEdit(displayInput, editInput) {
displayInput.style.display = "none";
editInput.style.display = "";
editInput.focus();
},
switchToDisplay(displayInput, editInput) {
editInput.style.display = "none";
displayInput.style.display = "";
displayInput.value = this.formatTime(
Number.parseFloat(editInput.value) || 0,
);
},
async saveSkipperData() {
const newTimestamps = {
Introduction: {
Start: Number.parseFloat(
document.getElementById("introStartEdit").value || 0,
),
End: Number.parseFloat(
document.getElementById("introEndEdit").value || 0,
),
},
Credits: {
Start: Number.parseFloat(
document.getElementById("creditsStartEdit").value || 0,
),
End: Number.parseFloat(
document.getElementById("creditsEndEdit").value || 0,
),
},
};
const { Introduction = {}, Credits = {} } = this.skipperData;
if (
newTimestamps.Introduction.Start !== (Introduction.Start || 0) ||
newTimestamps.Introduction.End !== (Introduction.End || 0) ||
newTimestamps.Credits.Start !== (Credits.Start || 0) ||
newTimestamps.Credits.End !== (Credits.End || 0)
) {
const response = await this.secureFetch(
`Episode/${this.currentEpisodeId}/Timestamps`,
"POST",
JSON.stringify(newTimestamps),
);
this.d(
response.ok
? "Timestamps updated successfully"
: "Failed to update timestamps:",
response.status,
);
} else {
this.d("Timestamps have not changed, skipping update");
}
},
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
async secureFetch(url, method = "GET", body = null) {
const response = await fetch(`${ApiClient.serverAddress()}/${url}`, {
method,
headers: Object.assign(
{ Authorization: `MediaBrowser Token=${ApiClient.accessToken()}` },
method === "POST" ? { "Content-Type": "application/json" } : {},
),
body,
});
return response.ok
? method === "POST"
? response
: response.json()
: response.status === 404
? null
: console.error(`Error ${response.status} from ${url}`) || null;
},
/** Handle keydown events. */
eventHandler(e) {
if (e.key !== "Enter") return;
e.stopPropagation();
e.preventDefault();
this.doSkip();
},
handleEscapeKey(e) {
if (e.key === "Escape" || e.keyCode === 461 || e.keyCode === 10009) {
e.stopPropagation();
this.closeSubmenu(true);
}
},
};
introSkipper.setup();

View File

@ -1,14 +1,18 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net.Mime; using System.Net.Mime;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Common.Api; using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers; namespace IntroSkipper.Controllers;
/// <summary> /// <summary>
/// Skip intro controller. /// Skip intro controller.
@ -63,7 +67,7 @@ public class SkipIntroController : ControllerBase
{ {
// only update existing episodes // only update existing episodes
var rawItem = Plugin.Instance!.GetItem(id); var rawItem = Plugin.Instance!.GetItem(id);
if (rawItem == null || rawItem is not Episode episode) if (rawItem == null || rawItem is not Episode and not Movie)
{ {
return NotFound(); return NotFound();
} }
@ -99,7 +103,7 @@ public class SkipIntroController : ControllerBase
{ {
// only get return content for episodes // only get return content for episodes
var rawItem = Plugin.Instance!.GetItem(id); var rawItem = Plugin.Instance!.GetItem(id);
if (rawItem == null || rawItem is not Episode episode) if (rawItem == null || rawItem is not Episode and not Movie)
{ {
return NotFound(); return NotFound();
} }
@ -156,18 +160,21 @@ public class SkipIntroController : ControllerBase
var segment = new Intro(timestamp); var segment = new Intro(timestamp);
var config = Plugin.Instance!.Configuration; var config = Plugin.Instance!.Configuration;
segment.IntroEnd -= config.RemainingSecondsOfIntro; segment.IntroEnd = mode == AnalysisMode.Credits
? GetAdjustedIntroEnd(id, segment.IntroEnd, config)
: segment.IntroEnd - config.RemainingSecondsOfIntro;
if (config.PersistSkipButton) if (config.PersistSkipButton)
{ {
segment.ShowSkipPromptAt = segment.IntroStart; segment.ShowSkipPromptAt = segment.IntroStart;
segment.HideSkipPromptAt = segment.IntroEnd; segment.HideSkipPromptAt = segment.IntroEnd - 3;
} }
else else
{ {
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment); segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
segment.HideSkipPromptAt = Math.Min( segment.HideSkipPromptAt = Math.Min(
segment.IntroStart + config.HidePromptAdjustment, segment.IntroStart + config.HidePromptAdjustment,
segment.IntroEnd); segment.IntroEnd - 3);
} }
return segment; return segment;
@ -178,6 +185,14 @@ public class SkipIntroController : ControllerBase
} }
} }
private static double GetAdjustedIntroEnd(Guid id, double segmentEnd, PluginConfiguration config)
{
var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds;
return runTime > 0 && runTime < segmentEnd + 1
? runTime
: segmentEnd - config.RemainingSecondsOfIntro;
}
/// <summary> /// <summary>
/// Erases all previously discovered introduction timestamps. /// Erases all previously discovered introduction timestamps.
/// </summary> /// </summary>

View File

@ -1,10 +1,13 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Net.Mime; using System.Net.Mime;
using System.Text; using System.Text;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Helper; using IntroSkipper.Helper;
using MediaBrowser.Common; using MediaBrowser.Common;
using MediaBrowser.Common.Api; using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
@ -12,7 +15,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers; namespace IntroSkipper.Controllers;
/// <summary> /// <summary>
/// Troubleshooting controller. /// Troubleshooting controller.

View File

@ -1,15 +1,18 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Net.Mime; using System.Net.Mime;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Common.Api; using MediaBrowser.Common.Api;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers; namespace IntroSkipper.Controllers;
/// <summary> /// <summary>
/// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis. /// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis.
@ -47,7 +50,7 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
var seasonNumber = first.SeasonNumber; var seasonNumber = first.SeasonNumber;
if (!showSeasons.TryGetValue(seriesId, out var showInfo)) if (!showSeasons.TryGetValue(seriesId, out var showInfo))
{ {
showInfo = new ShowInfos { SeriesName = first.SeriesName, ProductionYear = GetProductionYear(seriesId), LibraryName = GetLibraryName(seriesId), Seasons = [] }; showInfo = new ShowInfos { SeriesName = first.SeriesName, ProductionYear = GetProductionYear(seriesId), LibraryName = GetLibraryName(seriesId), IsMovie = first.IsMovie, Seasons = [] };
showSeasons[seriesId] = showInfo; showSeasons[seriesId] = showInfo;
} }
@ -65,6 +68,7 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
SeriesName = kvp.Value.SeriesName, SeriesName = kvp.Value.SeriesName,
ProductionYear = kvp.Value.ProductionYear, ProductionYear = kvp.Value.ProductionYear,
LibraryName = kvp.Value.LibraryName, LibraryName = kvp.Value.LibraryName,
IsMovie = kvp.Value.IsMovie,
Seasons = kvp.Value.Seasons Seasons = kvp.Value.Seasons
.OrderBy(s => s.Value) .OrderBy(s => s.Value)
.ToDictionary(s => s.Key, s => s.Value) .ToDictionary(s => s.Key, s => s.Value)

View File

@ -1,4 +1,7 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Type of media file analysis to perform. /// Type of media file analysis to perform.

View File

@ -1,4 +1,7 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// A frame of video that partially (or entirely) consists of black pixels. /// A frame of video that partially (or entirely) consists of black pixels.

View File

@ -1,4 +1,7 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Taken from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL. /// Taken from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL.
@ -11,32 +14,22 @@ public enum EdlAction
None = -1, None = -1,
/// <summary> /// <summary>
/// Completely remove the intro from playback as if it was never in the original video. /// Completely remove the segment from playback as if it was never in the original video.
/// </summary> /// </summary>
Cut, Cut = 0,
/// <summary> /// <summary>
/// Mute audio, continue playback. /// Mute audio, continue playback.
/// </summary> /// </summary>
Mute, Mute = 1,
/// <summary> /// <summary>
/// Inserts a new scene marker. /// Inserts a new scene marker.
/// </summary> /// </summary>
SceneMarker, SceneMarker = 2,
/// <summary> /// <summary>
/// Automatically skip the intro once during playback. /// Automatically skip once during playback.
/// </summary> /// </summary>
CommercialBreak, CommercialBreak = 3
/// <summary>
/// Show a skip button.
/// </summary>
Intro,
/// <summary>
/// Show a skip button.
/// </summary>
Credit,
} }

View File

@ -1,6 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Represents the state of an episode regarding analysis and blacklist status. /// Represents the state of an episode regarding analysis and blacklist status.

View File

@ -1,6 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Episode name and internal ID as returned by the visualization controller. /// Episode name and internal ID as returned by the visualization controller.

View File

@ -1,6 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Exception raised when an error is encountered analyzing audio. /// Exception raised when an error is encountered analyzing audio.

View File

@ -1,7 +1,10 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Runtime.Serialization; using System.Runtime.Serialization;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Represents an item to ignore. /// Represents an item to ignore.

View File

@ -1,7 +1,10 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Runtime.Serialization; using System.Runtime.Serialization;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Result of fingerprinting and analyzing two episodes in a season. /// Result of fingerprinting and analyzing two episodes in a season.

View File

@ -1,6 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Support bundle warning. /// Support bundle warning.

View File

@ -1,6 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Episode queued for analysis. /// Episode queued for analysis.
@ -47,6 +50,11 @@ public class QueuedEpisode
/// </summary> /// </summary>
public bool IsAnime { get; set; } public bool IsAnime { get; set; }
/// <summary>
/// Gets or sets a value indicating whether an item is a movie.
/// </summary>
public bool IsMovie { get; set; }
/// <summary> /// <summary>
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction at. /// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
/// </summary> /// </summary>

View File

@ -1,9 +1,12 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Globalization; using System.Globalization;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Result of fingerprinting and analyzing two episodes in a season. /// Result of fingerprinting and analyzing two episodes in a season.

View File

@ -1,7 +1,10 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers namespace IntroSkipper.Data
{ {
/// <summary> /// <summary>
/// Contains information about a show. /// Contains information about a show.
@ -23,6 +26,11 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers
/// </summary> /// </summary>
public required string LibraryName { get; set; } public required string LibraryName { get; set; }
/// <summary>
/// Gets or sets a value indicating whether its a movie.
/// </summary>
public required bool IsMovie { get; set; }
/// <summary> /// <summary>
/// Gets the Seasons of the show. /// Gets the Seasons of the show.
/// </summary> /// </summary>

View File

@ -1,6 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; namespace IntroSkipper.Data;
#pragma warning disable CA1036 // Override methods on comparable types #pragma warning disable CA1036 // Override methods on comparable types

View File

@ -1,7 +1,10 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Time range helpers. /// Time range helpers.

View File

@ -1,4 +1,7 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Data
{ {
/// <summary> /// <summary>
/// Result of fingerprinting and analyzing two episodes in a season. /// Result of fingerprinting and analyzing two episodes in a season.

View File

@ -1,4 +1,7 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Data;
/// <summary> /// <summary>
/// Warning manager. /// Warning manager.

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
@ -6,10 +9,10 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace IntroSkipper;
/// <summary> /// <summary>
/// Wrapper for libchromaprint and the silencedetect filter. /// Wrapper for libchromaprint and the silencedetect filter.

View File

@ -1,4 +1,7 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Helper // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Helper
{ {
/// <summary> /// <summary>
/// Gets the commit used to build the plugin. /// Gets the commit used to build the plugin.

View File

@ -1,12 +1,15 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Xml; using System.Xml;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
namespace ConfusedPolarBear.Plugin.IntroSkipper namespace IntroSkipper
{ {
internal sealed class XmlSerializationHelper internal sealed class XmlSerializationHelper
{ {

View File

@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace> <RootNamespace>IntroSkipper</RootNamespace>
<AssemblyVersion>1.0.0.5</AssemblyVersion> <AssemblyVersion>1.10.9.2</AssemblyVersion>
<FileVersion>1.0.0.5</FileVersion> <FileVersion>1.10.9.2</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -11,8 +11,8 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" /> <PackageReference Include="Jellyfin.Controller" Version="10.9.*" />
<PackageReference Include="Jellyfin.Model" Version="10.*-*" /> <PackageReference Include="Jellyfin.Model" Version="10.9.*" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.556" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.556" PrivateAssets="All" />

View File

@ -1,10 +1,13 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace IntroSkipper;
/// <summary> /// <summary>
/// Update EDL files associated with a list of episodes. /// Update EDL files associated with a list of episodes.
@ -97,14 +100,7 @@ public static class EdlManager
edlContent += Environment.NewLine; edlContent += Environment.NewLine;
} }
if (action == EdlAction.Intro) edlContent += credit?.ToEdl(action);
{
edlContent += credit?.ToEdl(EdlAction.Credit);
}
else
{
edlContent += credit?.ToEdl(action);
}
} }
File.WriteAllText(edlPath, edlContent); File.WriteAllText(edlPath, edlContent);

View File

@ -1,16 +1,20 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Extensions; using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace IntroSkipper;
/// <summary> /// <summary>
/// Manages enqueuing library items for analysis. /// Manages enqueuing library items for analysis.
@ -28,6 +32,7 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
private double _analysisPercent; private double _analysisPercent;
private List<string> _selectedLibraries = []; private List<string> _selectedLibraries = [];
private bool _selectAllLibraries; private bool _selectAllLibraries;
private bool _analyzeMovies;
/// <summary> /// <summary>
/// Gets all media items on the server. /// Gets all media items on the server.
@ -90,6 +95,8 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
_selectAllLibraries = config.SelectAllLibraries; _selectAllLibraries = config.SelectAllLibraries;
_analyzeMovies = config.AnalyzeMovies;
if (!_selectAllLibraries) if (!_selectAllLibraries)
{ {
// Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries. // Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries.
@ -123,7 +130,7 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
// Order by series name, season, and then episode number so that status updates are logged in order // Order by series name, season, and then episode number so that status updates are logged in order
ParentId = id, ParentId = id,
OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),], OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),],
IncludeItemTypes = [BaseItemKind.Episode], IncludeItemTypes = [BaseItemKind.Episode, BaseItemKind.Movie],
Recursive = true, Recursive = true,
IsVirtualItem = false IsVirtualItem = false
}; };
@ -141,13 +148,18 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
foreach (var item in items) foreach (var item in items)
{ {
if (item is not Episode episode) if (item is Episode episode)
{ {
_logger.LogDebug("Item {Name} is not an episode", item.Name); QueueEpisode(episode);
continue; }
else if (_analyzeMovies && item is Movie movie)
{
QueueMovie(movie);
}
else
{
_logger.LogDebug("Item {Name} is not an episode or movie", item.Name);
} }
QueueEpisode(episode);
} }
_logger.LogDebug("Queued {Count} episodes", items.Count); _logger.LogDebug("Queued {Count} episodes", items.Count);
@ -197,8 +209,11 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
duration >= 5 * 60 ? duration * _analysisPercent : duration, duration >= 5 * 60 ? duration * _analysisPercent : duration,
60 * pluginInstance.Configuration.AnalysisLengthLimit); 60 * pluginInstance.Configuration.AnalysisLengthLimit);
var maxCreditsDuration = Math.Min(
duration >= 5 * 60 ? duration * _analysisPercent : duration,
60 * pluginInstance.Configuration.MaximumCreditsDuration);
// Queue the episode for analysis // Queue the episode for analysis
var maxCreditsDuration = pluginInstance.Configuration.MaximumCreditsDuration;
seasonEpisodes.Add(new QueuedEpisode seasonEpisodes.Add(new QueuedEpisode
{ {
SeriesName = episode.SeriesName, SeriesName = episode.SeriesName,
@ -216,6 +231,34 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
pluginInstance.TotalQueued++; pluginInstance.TotalQueued++;
} }
private void QueueMovie(Movie movie)
{
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");
if (string.IsNullOrEmpty(movie.Path))
{
_logger.LogWarning(
"Not queuing movie \"{Name}\" ({Id}) as no path was provided by Jellyfin",
movie.Name,
movie.Id);
return;
}
// Allocate a new list for each Movie
_queuedEpisodes.TryAdd(movie.Id, []);
var duration = TimeSpan.FromTicks(movie.RunTimeTicks ?? 0).TotalSeconds;
_queuedEpisodes[movie.Id].Add(new QueuedEpisode
{
SeriesName = movie.Name,
SeriesId = movie.Id,
EpisodeId = movie.Id,
Name = movie.Name,
Path = movie.Path,
Duration = Convert.ToInt32(duration),
IsMovie = true
});
pluginInstance.TotalQueued++;
}
private Guid GetSeasonId(Episode episode) private Guid GetSeasonId(Episode episode)
{ {
if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special

View File

@ -1,11 +1,16 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using System.Xml;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using System.Xml.Serialization;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using MediaBrowser.Common; using MediaBrowser.Common;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
@ -16,9 +21,10 @@ using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Updates;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace IntroSkipper;
/// <summary> /// <summary>
/// Intro skipper plugin. Uses audio analysis to find common sequences of audio shared between episodes. /// Intro skipper plugin. Uses audio analysis to find common sequences of audio shared between episodes.
@ -85,6 +91,40 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
XmlSerializationHelper.MigrateXML(_introPath); XmlSerializationHelper.MigrateXML(_introPath);
XmlSerializationHelper.MigrateXML(_creditsPath); XmlSerializationHelper.MigrateXML(_creditsPath);
var oldConfigFile = Path.Join(applicationPaths.PluginConfigurationsPath, "ConfusedPolarBear.Plugin.IntroSkipper.xml");
if (File.Exists(oldConfigFile))
{
try
{
XmlSerializer serializer = new XmlSerializer(typeof(PluginConfiguration));
using (FileStream fileStream = new FileStream(oldConfigFile, FileMode.Open))
{
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit, // Disable DTD processing
XmlResolver = null // Disable the XmlResolver
};
using (var reader = XmlReader.Create(fileStream, settings))
{
if (serializer.Deserialize(reader) is PluginConfiguration oldConfig)
{
Instance.UpdateConfiguration(oldConfig);
File.Delete(oldConfigFile);
}
}
}
}
catch (Exception ex)
{
// Handle exceptions, such as file not found, deserialization errors, etc.
_logger.LogWarning("Something stupid happened: {Exception}", ex);
}
}
MigrateRepoUrl(serverConfiguration);
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete // TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
try try
{ {
@ -405,6 +445,53 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
SaveTimestamps(AnalysisMode.Credits); SaveTimestamps(AnalysisMode.Credits);
} }
private void MigrateRepoUrl(IServerConfigurationManager serverConfiguration)
{
try
{
List<string> oldRepos =
[
"https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/manifest.json",
"https://raw.githubusercontent.com/jumoog/intro-skipper/master/manifest.json",
"https://manifest.intro-skipper.workers.dev/manifest.json"
];
// Access the current server configuration
var config = serverConfiguration.Configuration;
// Get the list of current plugin repositories
var pluginRepositories = config.PluginRepositories.ToList();
// check if old plugins exits
if (pluginRepositories.Exists(repo => repo.Url != null && oldRepos.Contains(repo.Url)))
{
// remove all old plugins
pluginRepositories.RemoveAll(repo => repo.Url != null && oldRepos.Contains(repo.Url));
// Add repository only if it does not exit and the OverideManifestUrl Option is activated
if (!pluginRepositories.Exists(repo => repo.Url == "https://manifest.intro-skipper.org/manifest.json") && Instance!.Configuration.OverrideManifestUrl)
{
// Add the new repository to the list
pluginRepositories.Add(new RepositoryInfo
{
Name = "intro skipper (automatically migrated by plugin)",
Url = "https://manifest.intro-skipper.org/manifest.json",
Enabled = true,
});
}
// Update the configuration with the new repository list
config.PluginRepositories = [.. pluginRepositories];
// Save the updated configuration
serverConfiguration.SaveConfiguration();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred while migrating repo URL");
}
}
/// <summary> /// <summary>
/// Inject the skip button script into the web interface. /// Inject the skip button script into the web interface.
/// </summary> /// </summary>
@ -414,8 +501,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
string searchPattern = "dashboard-dashboard.*.chunk.js"; string searchPattern = "dashboard-dashboard.*.chunk.js";
string[] filePaths = Directory.GetFiles(webPath, searchPattern, SearchOption.TopDirectoryOnly); string[] filePaths = Directory.GetFiles(webPath, searchPattern, SearchOption.TopDirectoryOnly);
string pattern = @"buildVersion""\)\.innerText=""(?<buildVersion>\d+\.\d+\.\d+)"",.*?webVersion""\)\.innerText=""(?<webVersion>\d+\.\d+\.\d+)"; string pattern = @"buildVersion""\)\.innerText=""(?<buildVersion>\d+\.\d+\.\d+)"",.*?webVersion""\)\.innerText=""(?<webVersion>\d+\.\d+\.\d+)";
string buildVersionString = "unknow"; string webVersionString = "unknown";
string webVersionString = "unknow";
// Create a Regex object // Create a Regex object
Regex regex = new Regex(pattern); Regex regex = new Regex(pattern);
@ -428,14 +514,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
// search for buildVersion and webVersion // search for buildVersion and webVersion
if (match.Success) if (match.Success)
{ {
buildVersionString = match.Groups["buildVersion"].Value;
webVersionString = match.Groups["webVersion"].Value; webVersionString = match.Groups["webVersion"].Value;
_logger.LogInformation("Found jellyfin-web <{WebVersion}>", webVersionString); _logger.LogInformation("Found jellyfin-web <{WebVersion}>", webVersionString);
break; break;
} }
} }
if (webVersionString != "unknow") if (webVersionString != "unknown")
{ {
// append Revision // append Revision
webVersionString += ".0"; webVersionString += ".0";

View File

@ -1,8 +1,11 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace ConfusedPolarBear.Plugin.IntroSkipper namespace IntroSkipper
{ {
/// <summary> /// <summary>
/// Register Intro Skipper services. /// Register Intro Skipper services.

View File

@ -1,15 +1,18 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers; using IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; namespace IntroSkipper.ScheduledTasks;
/// <summary> /// <summary>
/// Common code shared by all media item analyzer tasks. /// Common code shared by all media item analyzer tasks.
@ -59,12 +62,11 @@ public class BaseItemAnalyzerTask
CancellationToken cancellationToken, CancellationToken cancellationToken,
IReadOnlyCollection<Guid>? seasonsToAnalyze = null) IReadOnlyCollection<Guid>? seasonsToAnalyze = null)
{ {
var ffmpegValid = FFmpegWrapper.CheckFFmpegVersion();
// Assert that ffmpeg with chromaprint is installed // Assert that ffmpeg with chromaprint is installed
if (Plugin.Instance!.Configuration.WithChromaprint && !ffmpegValid) if (Plugin.Instance!.Configuration.WithChromaprint && !FFmpegWrapper.CheckFFmpegVersion())
{ {
throw new FingerprintException( throw new FingerprintException(
"Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg5. If Jellyfin is running in a container, upgrade to version 10.8.0 or newer."); "Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg6. If Jellyfin is running in a container, upgrade to version 10.9.0 or newer.");
} }
var queueManager = new QueueManager( var queueManager = new QueueManager(
@ -122,11 +124,8 @@ public class BaseItemAnalyzerTask
Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly
progress.Report(totalProcessed * 100 / totalQueued); progress.Report(totalProcessed * 100 / totalQueued);
return;
} }
else if (_analysisModes.Count != requiredModes.Count)
if (_analysisModes.Count != requiredModes.Count)
{ {
Interlocked.Add(ref totalProcessed, episodes.Count); Interlocked.Add(ref totalProcessed, episodes.Count);
progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed
@ -188,7 +187,7 @@ public class BaseItemAnalyzerTask
// Only analyze specials (season 0) if the user has opted in. // Only analyze specials (season 0) if the user has opted in.
var first = items[0]; var first = items[0];
if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero) if (!first.IsMovie && first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
{ {
return 0; return 0;
} }
@ -211,7 +210,7 @@ public class BaseItemAnalyzerTask
new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()) new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>())
}; };
if (first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint) if (first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint && !first.IsMovie)
{ {
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>())); analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
} }
@ -221,7 +220,7 @@ public class BaseItemAnalyzerTask
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>())); analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
} }
if (!first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint) if (!first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint && !first.IsMovie)
{ {
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>())); analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
} }

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
@ -8,7 +11,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; namespace IntroSkipper.ScheduledTasks;
/// <summary> /// <summary>
/// Analyze all television episodes for introduction sequences. /// Analyze all television episodes for introduction sequences.

View File

@ -1,14 +1,16 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; namespace IntroSkipper.ScheduledTasks;
/// <summary> /// <summary>
/// Analyze all television episodes for credits. /// Analyze all television episodes for credits.

View File

@ -1,14 +1,16 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; namespace IntroSkipper.ScheduledTasks;
/// <summary> /// <summary>
/// Analyze all television episodes for introduction sequences. /// Analyze all television episodes for introduction sequences.

View File

@ -1,14 +1,16 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; namespace IntroSkipper.ScheduledTasks;
/// <summary> /// <summary>
/// Analyze all television episodes for introduction sequences. /// Analyze all television episodes for introduction sequences.

View File

@ -1,7 +1,10 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Threading; using System.Threading;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; namespace IntroSkipper.ScheduledTasks;
internal sealed class ScheduledTaskSemaphore : IDisposable internal sealed class ScheduledTaskSemaphore : IDisposable
{ {

View File

@ -1,10 +1,13 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Timers; using System.Timers;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using IntroSkipper.Configuration;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Session;
@ -15,7 +18,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer; using Timer = System.Timers.Timer;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace IntroSkipper;
/// <summary> /// <summary>
/// Automatically skip past introduction sequences. /// Automatically skip past introduction sequences.

View File

@ -1,10 +1,13 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Timers; using System.Timers;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using IntroSkipper.Configuration;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Session;
@ -15,7 +18,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer; using Timer = System.Timers.Timer;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace IntroSkipper;
/// <summary> /// <summary>
/// Automatically skip past credit sequences. /// Automatically skip past credit sequences.

View File

@ -1,10 +1,13 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data; using IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks; using IntroSkipper.ScheduledTasks;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
@ -13,7 +16,7 @@ using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace IntroSkipper;
/// <summary> /// <summary>
/// Server entrypoint. /// Server entrypoint.

View File

@ -9,10 +9,14 @@
</p> </p>
[![CodeQL](https://github.com/intro-skipper/intro-skipper/actions/workflows/codeql.yml/badge.svg)](https://github.com/intro-skipper/intro-skipper/actions/workflows/codeql.yml) [![CodeQL](https://github.com/intro-skipper/intro-skipper/actions/workflows/codeql.yml/badge.svg)](https://github.com/intro-skipper/intro-skipper/actions/workflows/codeql.yml)
https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/manifest.json
</div> </div>
## Manifest URL (All Jellyfin Versions)
```
https://manifest.intro-skipper.org/manifest.json
```
## System requirements ## System requirements
* Jellyfin 10.9.11 (or newer) * Jellyfin 10.9.11 (or newer)
@ -26,29 +30,15 @@
* SyncPlay is not (yet) compatible with any method of skipping due to the nature of how the clients are synced. * SyncPlay is not (yet) compatible with any method of skipping due to the nature of how the clients are synced.
## Jellyfin 10.8 (previous version) ## [Detection parameters](https://github.com/intro-skipper/intro-skipper/wiki#detection-parameters)
👉👉👉 [Jellyfin 10.8 Instructions](https://github.com/intro-skipper/intro-skipper/blob/10.8/README.md)
## Detection parameters ## [Detection types](https://github.com/intro-skipper/intro-skipper/wiki#detection-types)
Show introductions will be detected if they are:
* Located within the first 25% of an episode or the first 10 minutes, whichever is smaller
* Between 15 seconds and 2 minutes long
Ending credits will be detected if they are shorter than 4 minutes.
These parameters can be configured by opening the plugin settings
## [Installation](https://github.com/intro-skipper/intro-skipper/wiki/Installation) ## [Installation](https://github.com/intro-skipper/intro-skipper/wiki/Installation)
- #### [Install the plugin](https://github.com/intro-skipper/intro-skipper/wiki/Installation#step-1-install-the-plugin)
- #### [Verify the plugin](https://github.com/intro-skipper/intro-skipper/wiki/Installation#step-2-verify-the-plugin) ## [Jellyfin Skip Options](https://github.com/intro-skipper/intro-skipper/wiki/Jellyfin-Skip-Options)
- #### [Custom FFMPEG (MacOS)](https://github.com/intro-skipper/intro-skipper/wiki/Custom-FFMPEG-(MacOS))
## [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting) ## [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting)
- #### [Scheduled tasks fail instantly](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#scheduled-tasks-fail-instantly)
- #### [Skip button is not visible](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible)
- #### [Autoskip is not working](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#autoskip-is-not-working)
## [API Documentation](https://github.com/intro-skipper/intro-skipper/blob/master/docs/api.md) ## [API Documentation](https://github.com/intro-skipper/intro-skipper/blob/master/docs/api.md)

1
VERSION.txt Normal file
View File

@ -0,0 +1 @@
10.9

View File

@ -21,7 +21,7 @@ GOTO UserInput
:FoundFile :FoundFile
echo "%NewestFile%" echo "%NewestFile%"
xcopy /y ConfusedPolarBear.Plugin.IntroSkipper.dll "%NewestFile%" xcopy /y IntroSkipper.dll "%NewestFile%"
:UserInput :UserInput
@pause @pause

View File

@ -6,8 +6,8 @@ if [ "$(uname)" == "Darwin" ]; then
echo "Intro Skipper plugin not found!" echo "Intro Skipper plugin not found!"
exit exit
fi fi
cp -f ConfusedPolarBear.Plugin.IntroSkipper*.dll \ cp -f IntroSkipper*.dll \
"$plugin/ConfusedPolarBear.Plugin.IntroSkipper.dll" "$plugin/IntroSkipper.dll"
else else
echo "Jellyfin plugin directory not found!" echo "Jellyfin plugin directory not found!"
fi fi
@ -19,8 +19,8 @@ elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then
echo "Intro Skipper plugin not found!" echo "Intro Skipper plugin not found!"
exit exit
fi fi
cp -f ConfusedPolarBear.Plugin.IntroSkipper*.dll \ cp -f IntroSkipper*.dll \
"$plugin/ConfusedPolarBear.Plugin.IntroSkipper.dll" "$plugin/IntroSkipper.dll"
else else
echo "Jellyfin plugin directory not found!" echo "Jellyfin plugin directory not found!"
fi fi

Some files were not shown because too many files have changed in this diff Show More