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
csharp_preserve_single_line_statements = true
csharp_preserve_single_line_blocks = true
file_header_template = Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>\nSPDX-License-Identifier: GPL-3.0-only.

View File

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

View File

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

View File

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

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ docker/dist
# Visual Studio
.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>
<ProjectReference Include="..\ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj" />
<ProjectReference Include="..\IntroSkipper\IntroSkipper.csproj" />
</ItemGroup>
</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
* which supports both chromaprint and the "-fp_format raw" flag.
*/
using System;
using System.Collections.Generic;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Analyzers;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
using Xunit;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
namespace IntroSkipper.Tests;
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.Collections.Generic;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Analyzers;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
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.Collections.Generic;
using System.Collections.ObjectModel;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Analyzers;
using IntroSkipper.Data;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
using Xunit;

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;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
namespace IntroSkipper.Tests;
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 ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Data;
using Xunit;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
namespace IntroSkipper.Tests;
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;
public class TestFlags

View File

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

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.Collections.Generic;
using System.Linq;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
namespace IntroSkipper;
/// <summary>
/// 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.Collections.Generic;
using System.Linq;
using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
namespace IntroSkipper.Analyzers;
/// <summary>
/// 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 _maximumMovieCreditsDuration;
private readonly int _blackFrameMinimumPercentage;
/// <summary>
@ -32,7 +37,8 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
{
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
_minimumCreditsDuration = config.MinimumCreditsDuration;
_maximumCreditsDuration = 2 * config.MaximumCreditsDuration;
_maximumCreditsDuration = config.MaximumCreditsDuration;
_maximumMovieCreditsDuration = config.MaximumMovieCreditsDuration;
_blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage;
_logger = logger;
@ -66,7 +72,21 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
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)
{
var scanTime = episode.Duration - searchStart;
@ -83,9 +103,9 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
if (searchStart > _maximumCreditsDuration)
if (searchStart > creditDuration)
{
searchStart = _maximumCreditsDuration;
searchStart = creditDuration;
break;
}
}
@ -104,7 +124,7 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
searchDistance,
_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.
searchStart = _minimumCreditsDuration;
@ -143,6 +163,8 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
var end = TimeSpan.FromSeconds(lowerLimit);
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
// frame is smaller than the maximum permitted error.
while (start - end > _maximumError)
@ -189,7 +211,7 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
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
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.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
namespace IntroSkipper.Analyzers;
/// <summary>
/// Chapter name analyzer.
@ -56,7 +58,7 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
expression,
mode);
if (skipRange is null)
if (skipRange is null || !skipRange.Valid)
{
continue;
}
@ -92,9 +94,10 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
}
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var creditDuration = episode.IsMovie ? config.MaximumMovieCreditsDuration : config.MaximumCreditsDuration;
var reversed = mode != AnalysisMode.Introduction;
var (minDuration, maxDuration) = reversed
? (config.MinimumCreditsDuration, config.MaximumCreditsDuration)
? (config.MinimumCreditsDuration, creditDuration)
: (config.MinimumIntroDuration, config.MaximumIntroDuration);
// 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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
namespace IntroSkipper.Analyzers;
/// <summary>
/// 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.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Data;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
namespace IntroSkipper.Analyzers;
/// <summary>
/// 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.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
namespace IntroSkipper.Analyzers;
/// <summary>
/// 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 ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Data;
using MediaBrowser.Model.Plugins;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
namespace IntroSkipper.Configuration;
/// <summary>
/// Plugin configuration.
@ -18,11 +21,6 @@ public class PluginConfiguration : BasePluginConfiguration
// ===== Analysis settings =====
/// <summary>
/// Gets or sets the max degree of parallelism used when analyzing episodes.
/// </summary>
public int MaxParallelism { get; set; } = 2;
/// <summary>
/// Gets or sets the comma separated list of library names to analyze.
/// </summary>
@ -33,6 +31,11 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary>
public bool SelectAllLibraries { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether movies should be analyzed.
/// </summary>
public bool AnalyzeMovies { get; set; }
/// <summary>
/// Gets or sets the list of client to auto skip for.
/// </summary>
@ -107,7 +110,12 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary>
/// Gets or sets the upper limit (in seconds) on the length of each episode's audio track that will be analyzed when searching for ending credits.
/// </summary>
public int MaximumCreditsDuration { get; set; } = 300;
public int MaximumCreditsDuration { get; set; } = 450;
/// <summary>
/// Gets or sets the upper limit (in seconds) on the length of a movie segment that will be analyzed when searching for ending credits.
/// </summary>
public int MaximumMovieCreditsDuration { get; set; } = 900;
/// <summary>
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
@ -235,12 +243,22 @@ public class PluginConfiguration : BasePluginConfiguration
public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
/// <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>
public int ProcessThreads { get; set; }
/// <summary>
/// Gets or sets the relative priority for an ffmpeg process.
/// Gets or sets the relative priority for a ffmpeg process.
/// </summary>
public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal;
/// <summary>
/// Gets or sets a value indicating whether the ManifestUrl is self-managed, e.g. for mainland China.
/// </summary>
public bool OverrideManifestUrl { get; set; }
}

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>
/// User interface configuration.

View File

@ -32,46 +32,43 @@
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoDetectIntros" type="checkbox" is="emby-checkbox" />
<span>Automatically Scan Intros</span>
<span>Automatically Analyze Intros</span>
</label>
<div class="fieldDescription">If enabled, introductions will be automatically analyzed for new media</div>
<div class="fieldDescription">If enabled, new media will be automatically analyzed for introductions</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoDetectCredits" type="checkbox" is="emby-checkbox" />
<span>Automatically Scan Credits</span>
<span>Automatically Analyze Credits</span>
</label>
<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 />
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 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">
<label class="emby-checkbox-label">
<input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" />
<span>Analyze season 0</span>
<span>Analyze Season 0 (Specials / Extras)</span>
</label>
<div class="fieldDescription">
If checked, season 0 (specials / extras) will be included in analysis.
<br />
<br />
Note: Shows containing both a specials and extra folder will identify extras as season 0 and ignore specials, regardless of this setting.
</div>
</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">
<label class="emby-checkbox-label">
<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>
<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>
<p>
If the audio percentage or maximum runtime settings are modified, the cached fingerprints and introduction timestamps for each season you want to analyze with the modified settings <strong>will have to be deleted.</strong>
Increasing either of these settings will cause episode analysis to take much longer.
</p>
<div class="inputContainer" id="movieCreditsDuration">
<label class="inputLabel inputLabelUnfocused" for="MaximumMovieCreditsDuration"> Maximum movie credits duration (in seconds) </label>
<input id="MaximumMovieCreditsDuration" type="number" is="emby-input" min="1" />
<div class="fieldDescription">Segments longer than this duration will not be considered movie credits.</div>
</div>
</details>
<details id="edl">
@ -150,8 +145,6 @@
<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="SceneMarker">Scene Marker (create a chapter marker)</option>
@ -166,13 +159,23 @@
</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">
<label class="emby-checkbox-label">
<input id="RegenerateEdlFiles" type="checkbox" is="emby-checkbox" />
<span>Regenerate EDL files during next scan</span>
</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>
</details>
@ -197,6 +200,12 @@
<summary>Process Configuration</summary>
<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">
<label class="emby-checkbox-label">
<input id="CacheFingerprints" type="checkbox" is="emby-checkbox" />
@ -206,7 +215,7 @@
<div class="fieldDescription">
If checked, episode fingerprints will be saved on the filesystem to improve analysis speed.
<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 />
</div>
</div>
@ -248,11 +257,11 @@
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoSkip" type="checkbox" is="emby-checkbox" />
<span>Automatically skip intros</span>
<span>Automatically Skip Intros</span>
</label>
<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 />
</div>
</div>
@ -260,7 +269,7 @@
<div id="divSkipFirstEpisode" class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<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>
<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 />
</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">
<label class="emby-checkbox-label">
<input id="AutoSkipCredits" type="checkbox" is="emby-checkbox" />
<span>Automatically skip credits</span>
<span>Automatically Skip Credits</span>
</label>
<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 />
</div>
</div>
@ -296,16 +311,18 @@
<div id="SkipButtonContainer" class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<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>
<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 />
</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">
<summary>Auto Skip Client List</summary>
@ -315,15 +332,19 @@
<input id="ClientList" type="hidden" is="emby-input" />
</details>
<details>
<summary>User Interface Customization</summary>
<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 intro duration</span>
<span>Display Button for Segment Duration</span>
</label>
<div class="fieldDescription">
If checked, skip button will remain visible throught the intro (offset and timeout are ignored).
If checked, skip button will remain visible for the entire intro (offset and timeout are ignored).
<br />
Note: If unchecked, button will only appear in the player controls after the set timeout.
</div>
@ -350,10 +371,6 @@
<div class="fieldDescription">Seconds of introduction ending that should be played. Defaults to 2.</div>
</div>
<details>
<summary>User Interface Customization</summary>
<br />
<div class="inputContainer">
<label class="inputLabel" for="SkipButtonIntroText"> Skip intro button text </label>
<input id="SkipButtonIntroText" type="text" is="emby-input" />
@ -390,20 +407,16 @@
<fieldset class="verticalSection-extrabottompadding">
<legend>Advanced</legend>
<details id="support">
<summary>Support Bundle Info</summary>
<textarea id="supportBundle" rows="20" cols="75" readonly></textarea>
</details>
<details id="visualizer">
<summary>Manage Timestamps & Fingerprints</summary>
<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>
<label class="inputLabel" for="troubleshooterSeason">Select Season to manage</label>
<div id="seasonSelection">
<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 />
<div id="ignorelistSection" style="display: none">
@ -426,16 +439,18 @@
</div>
<br />
<button id="saveIgnoreListSeries" class="raised button-submit block emby-button">Apply to series</button>
<button id="saveIgnoreListSeason" class="raised button-submit block emby-button" style="display: none">Apply to season</button>
<button is="emby-button" id="saveIgnoreListSeries" class="raised button-submit block emby-button">Apply to series / movie</button>
<button is="emby-button" id="saveIgnoreListSeason" class="raised button-submit block emby-button" style="display: none">Apply to season</button>
</div>
<br />
<div id="episodeSelection">
<label class="inputLabel" for="troubleshooterEpisode1">Select the first episode</label>
<select is="emby-select" id="troubleshooterEpisode1" class="emby-select-withcolor emby-select"></select>
<label class="inputLabel" for="troubleshooterEpisode1">Select the second episode</label>
<select is="emby-select" id="troubleshooterEpisode2" class="emby-select-withcolor emby-select"></select>
<br />
</div>
<div id="timestampEditor" style="display: none">
<h3 style="margin: 0">Introduction timestamp editor</h3>
@ -467,6 +482,7 @@
</div>
</div>
<br />
<div id="rightEpisodeEditor">
<h4 style="margin: 0" id="editRightEpisodeTitle"></h4>
<br />
<div class="inlineForm">
@ -494,16 +510,20 @@
</div>
</div>
<br />
<button is="emby-select" id="btnUpdateTimestamps" class="raised button-submit block emby-button" type="button">Update timestamps</button>
</div>
<button is="emby-button" id="btnUpdateTimestamps" class="raised button-submit block emby-button" type="button">Update timestamps</button>
<br />
</div>
<div id="timestampErrorDiv" style="display: none">
<br />
<textarea id="timestampError" rows="2" cols="75" readonly></textarea>
<br />
<br />
</div>
<div id="fingerprintVisualizer" style="display: none">
<h3>Fingerprint Visualizer</h3>
<p>
Interactively compare the audio fingerprints of two episodes. <br />
@ -540,41 +560,60 @@
<span>Shift amount:</span>
<input type="number" min="-3000" max="3000" value="0" id="offset" />
<br />
<span id="suggestedShifts">Suggested shifts:</span>
<br />
<br />
<canvas id="troubleshooter" style="display: none"></canvas>
<span id="timestampContainer">
<span id="timestamps"></span> <br />
<span id="intros"></span>
<span id="suggestedShifts">
<span>Suggested shifts: </span>
</span>
<br />
<br />
<div id="eraseSeasonContainer" style="display: none">
<button id="btnEraseSeasonTimestamps" 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>
<canvas id="troubleshooter" style="display: none"></canvas>
<span id="timestampContainer">
<span id="timestamps"></span>
<br />
<span id="intros"></span>
</span>
<br />
</div>
<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 />
<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 />
<input type="checkbox" id="eraseModeCacheCheckbox" style="margin-left: 10px" />
<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 id="storage">
<br />
<summary>Storage Usage</summary>
<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>
</fieldset>
</form>
@ -616,6 +655,7 @@
"MaximumIntroDuration",
"MinimumCreditsDuration",
"MaximumCreditsDuration",
"MaximumMovieCreditsDuration",
"EdlAction",
"ProcessPriority",
"ProcessThreads",
@ -635,7 +675,7 @@
"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
var ignorelistSection = document.querySelector("div#ignorelistSection");
@ -645,20 +685,27 @@
var saveIgnoreListSeriesButton = ignorelistSection.querySelector("button#saveIgnoreListSeries");
var canvas = document.querySelector("canvas#troubleshooter");
var selectShow = document.querySelector("select#troubleshooterShow");
var seasonSelection = document.getElementById("seasonSelection");
var selectSeason = document.querySelector("select#troubleshooterSeason");
var episodeSelection = document.getElementById("episodeSelection");
var selectEpisode1 = document.querySelector("select#troubleshooterEpisode1");
var selectEpisode2 = document.querySelector("select#troubleshooterEpisode2");
var txtOffset = document.querySelector("input#offset");
var txtSuggested = document.querySelector("span#suggestedShifts");
var btnSeasonEraseTimestamps = document.querySelector("button#btnEraseSeasonTimestamps");
var eraseSeasonContainer = document.getElementById("eraseSeasonContainer");
var btnMovieEraseTimestamps = document.querySelector("button#btnEraseMovieTimestamps");
var eraseMovieContainer = document.getElementById("eraseMovieContainer");
var timestampError = document.querySelector("textarea#timestampError");
var timestampEditor = document.querySelector("#timestampEditor");
var rightEpisodeEditor = document.getElementById("rightEpisodeEditor");
var btnUpdateTimestamps = document.querySelector("button#btnUpdateTimestamps");
var timeContainer = document.querySelector("span#timestampContainer");
var fingerprintVisualizer = document.getElementById("fingerprintVisualizer");
var windowHashInterval = 0;
var analyzeMovies = document.getElementById("AnalyzeMovies");
var autoSkip = document.querySelector("input#AutoSkip");
var skipButtonVisible = document.getElementById("SkipButtonVisible");
var skipButtonVisibleLabel = document.getElementById("SkipButtonVisibleLabel");
@ -669,6 +716,7 @@
var secondsOfIntroStartToPlay = document.querySelector("div#divSecondsOfIntroStartToPlay");
var autoSkipClientList = document.getElementById("AutoSkipClientList");
var secondsOfCreditsStartToPlay = document.querySelector("div#divSecondsOfCreditsStartToPlay");
var movieCreditsDuration = document.getElementById("movieCreditsDuration");
var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText");
var autoSkipCredits = document.querySelector("input#AutoSkipCredits");
var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText");
@ -692,15 +740,15 @@
} else if (autoSkip.checked) {
autoSkipClientList.style.display = "unset";
autoSkipClientList.style.width = "100%";
skipButtonVisibleLabel.textContent = "Show skip credit button";
skipButtonVisibleLabel.textContent = "Show Skip Credit Button";
} else if (autoSkipCredits.checked) {
autoSkipClientList.style.display = "unset";
autoSkipClientList.style.width = "100%";
skipButtonVisibleLabel.textContent = "Show skip intro button";
skipButtonVisibleLabel.textContent = "Show Skip Intro Button";
} else {
autoSkipClientList.style.display = "unset";
autoSkipClientList.style.width = "100%";
skipButtonVisibleLabel.textContent = "Show skip intro / credit button";
skipButtonVisibleLabel.textContent = "Show All Skip Buttons";
}
skipButtonVisibleChanged();
}
@ -780,7 +828,7 @@
async function populateLibraries() {
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");
generateCheckboxList(libraryNames, "libraryCheckboxes", "SelectedLibraries");
}
@ -802,6 +850,16 @@
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
async function visualizerToggled() {
if (!visualizer.open) {
@ -887,14 +945,17 @@
const bundleText = await bundle.text();
// Display it to the user
const ta = document.querySelector("textarea#storage");
const ta = document.querySelector("textarea#storageText");
ta.value = bundleText;
}
// show changed, populate seasons
async function showChanged() {
seasonSelection.style.display = "unset";
clearSelect(selectSeason);
eraseSeasonContainer.style.display = "none";
eraseMovieContainer.style.display = "none";
episodeSelection.style.display = "unset";
clearSelect(selectEpisode1);
clearSelect(selectEpisode2);
@ -907,6 +968,13 @@
saveIgnoreListSeasonButton.style.display = "none";
Dashboard.hideLoadingMsg();
if (shows[selectShow.value].IsMovie) {
movieLoaded();
return;
}
saveIgnoreListSeriesButton.textContent = "Apply to series";
// add all seasons from this show to the season select
for (const season in shows[selectShow.value].Seasons) {
addItem(selectSeason, "Season " + shows[selectShow.value].Seasons[season], season);
@ -957,20 +1025,23 @@
Dashboard.showLoadingMsg();
timestampError.value = "";
fingerprintVisualizer.style.display = "unset";
canvas.style.display = "none";
lhs = await getJson("Intros/Episode/" + selectEpisode1.value + "/Chromaprint");
if (lhs === undefined) {
timestampError.value += "Error: " + selectEpisode1.value + " fingerprints failed!\n";
} 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");
if (rhs === undefined) {
timestampError.value += "Error: " + selectEpisode2.value + " fingerprints failed!";
} else if (rhs === null) {
timestampError.value += "Error: " + selectEpisode2.value + " fingerprints missing!\n";
timestampError.value += selectEpisode2.value + " fingerprints missing or incomplete.\n";
}
if (timestampError.value == "") {
@ -988,6 +1059,58 @@
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() {
const timestampEditor = document.getElementById("timestampEditor");
timestampEditor.querySelectorAll(".inputContainer").forEach((container) => {
@ -1215,8 +1338,7 @@
}
if (config["SkipButtonWarning"]) {
document.getElementById("SkipButtonContainer").style.display = "none";
document.getElementById("PersistContainer").style.display = "none";
document.getElementById("warningMessage").style.display = "unset";
} else {
document.getElementById("warningMessage").style.display = "none";
}
@ -1285,6 +1407,22 @@
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", () => {
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.Collections.Generic;
using System.Net.Mime;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
namespace IntroSkipper.Controllers;
/// <summary>
/// Skip intro controller.
@ -63,7 +67,7 @@ public class SkipIntroController : ControllerBase
{
// only update existing episodes
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();
}
@ -99,7 +103,7 @@ public class SkipIntroController : ControllerBase
{
// only get return content for episodes
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();
}
@ -156,18 +160,21 @@ public class SkipIntroController : ControllerBase
var segment = new Intro(timestamp);
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)
{
segment.ShowSkipPromptAt = segment.IntroStart;
segment.HideSkipPromptAt = segment.IntroEnd;
segment.HideSkipPromptAt = segment.IntroEnd - 3;
}
else
{
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
segment.HideSkipPromptAt = Math.Min(
segment.IntroStart + config.HidePromptAdjustment,
segment.IntroEnd);
segment.IntroEnd - 3);
}
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>
/// Erases all previously discovered introduction timestamps.
/// </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.Globalization;
using System.IO;
using System.Net.Mime;
using System.Text;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.Helper;
using IntroSkipper.Data;
using IntroSkipper.Helper;
using MediaBrowser.Common;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Library;
@ -12,7 +15,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
namespace IntroSkipper.Controllers;
/// <summary>
/// Troubleshooting controller.

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

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>
/// 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>
/// 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>
/// Taken from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL.
@ -11,32 +14,22 @@ public enum EdlAction
None = -1,
/// <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>
Cut,
Cut = 0,
/// <summary>
/// Mute audio, continue playback.
/// </summary>
Mute,
Mute = 1,
/// <summary>
/// Inserts a new scene marker.
/// </summary>
SceneMarker,
SceneMarker = 2,
/// <summary>
/// Automatically skip the intro once during playback.
/// Automatically skip once during playback.
/// </summary>
CommercialBreak,
/// <summary>
/// Show a skip button.
/// </summary>
Intro,
/// <summary>
/// Show a skip button.
/// </summary>
Credit,
CommercialBreak = 3
}

View File

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

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.Collections.Generic;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers
namespace IntroSkipper.Data
{
/// <summary>
/// Contains information about a show.
@ -23,6 +26,11 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers
/// </summary>
public required string LibraryName { get; set; }
/// <summary>
/// Gets or sets a value indicating whether its a movie.
/// </summary>
public required bool IsMovie { get; set; }
/// <summary>
/// Gets the Seasons of the show.
/// </summary>

View File

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

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.Collections.Generic;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data;
namespace IntroSkipper.Data;
/// <summary>
/// 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>
/// 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>
/// 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.Collections.Concurrent;
using System.Collections.Generic;
@ -6,10 +9,10 @@ using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
namespace IntroSkipper;
/// <summary>
/// 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>
/// 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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using System.Xml;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Data;
namespace ConfusedPolarBear.Plugin.IntroSkipper
namespace IntroSkipper
{
internal sealed class XmlSerializationHelper
{

View File

@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace>
<AssemblyVersion>1.0.0.5</AssemblyVersion>
<FileVersion>1.0.0.5</FileVersion>
<RootNamespace>IntroSkipper</RootNamespace>
<AssemblyVersion>1.10.9.2</AssemblyVersion>
<FileVersion>1.10.9.2</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
@ -11,8 +11,8 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" />
<PackageReference Include="Jellyfin.Model" Version="10.*-*" />
<PackageReference Include="Jellyfin.Controller" Version="10.9.*" />
<PackageReference Include="Jellyfin.Model" Version="10.9.*" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" 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.Collections.Generic;
using System.IO;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
namespace IntroSkipper;
/// <summary>
/// Update EDL files associated with a list of episodes.
@ -97,15 +100,8 @@ public static class EdlManager
edlContent += Environment.NewLine;
}
if (action == EdlAction.Intro)
{
edlContent += credit?.ToEdl(EdlAction.Credit);
}
else
{
edlContent += credit?.ToEdl(action);
}
}
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.Collections.Generic;
using System.IO;
using System.Linq;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
namespace IntroSkipper;
/// <summary>
/// Manages enqueuing library items for analysis.
@ -28,6 +32,7 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
private double _analysisPercent;
private List<string> _selectedLibraries = [];
private bool _selectAllLibraries;
private bool _analyzeMovies;
/// <summary>
/// Gets all media items on the server.
@ -90,6 +95,8 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
_selectAllLibraries = config.SelectAllLibraries;
_analyzeMovies = config.AnalyzeMovies;
if (!_selectAllLibraries)
{
// 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
ParentId = id,
OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),],
IncludeItemTypes = [BaseItemKind.Episode],
IncludeItemTypes = [BaseItemKind.Episode, BaseItemKind.Movie],
Recursive = true,
IsVirtualItem = false
};
@ -141,14 +148,19 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
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);
continue;
}
QueueEpisode(episode);
}
else if (_analyzeMovies && item is Movie movie)
{
QueueMovie(movie);
}
else
{
_logger.LogDebug("Item {Name} is not an episode or movie", item.Name);
}
}
_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,
60 * pluginInstance.Configuration.AnalysisLengthLimit);
var maxCreditsDuration = Math.Min(
duration >= 5 * 60 ? duration * _analysisPercent : duration,
60 * pluginInstance.Configuration.MaximumCreditsDuration);
// Queue the episode for analysis
var maxCreditsDuration = pluginInstance.Configuration.MaximumCreditsDuration;
seasonEpisodes.Add(new QueuedEpisode
{
SeriesName = episode.SeriesName,
@ -216,6 +231,34 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
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)
{
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.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using System.Xml;
using System.Xml.Serialization;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
@ -16,9 +21,10 @@ using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Updates;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
namespace IntroSkipper;
/// <summary>
/// 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(_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
try
{
@ -405,6 +445,53 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
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>
/// Inject the skip button script into the web interface.
/// </summary>
@ -414,8 +501,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
string searchPattern = "dashboard-dashboard.*.chunk.js";
string[] filePaths = Directory.GetFiles(webPath, searchPattern, SearchOption.TopDirectoryOnly);
string pattern = @"buildVersion""\)\.innerText=""(?<buildVersion>\d+\.\d+\.\d+)"",.*?webVersion""\)\.innerText=""(?<webVersion>\d+\.\d+\.\d+)";
string buildVersionString = "unknow";
string webVersionString = "unknow";
string webVersionString = "unknown";
// Create a Regex object
Regex regex = new Regex(pattern);
@ -428,14 +514,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
// search for buildVersion and webVersion
if (match.Success)
{
buildVersionString = match.Groups["buildVersion"].Value;
webVersionString = match.Groups["webVersion"].Value;
_logger.LogInformation("Found jellyfin-web <{WebVersion}>", webVersionString);
break;
}
}
if (webVersionString != "unknow")
if (webVersionString != "unknown")
{
// append Revision
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.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace ConfusedPolarBear.Plugin.IntroSkipper
namespace IntroSkipper
{
/// <summary>
/// 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.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Analyzers;
using IntroSkipper.Data;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
namespace IntroSkipper.ScheduledTasks;
/// <summary>
/// Common code shared by all media item analyzer tasks.
@ -59,12 +62,11 @@ public class BaseItemAnalyzerTask
CancellationToken cancellationToken,
IReadOnlyCollection<Guid>? seasonsToAnalyze = null)
{
var ffmpegValid = FFmpegWrapper.CheckFFmpegVersion();
// Assert that ffmpeg with chromaprint is installed
if (Plugin.Instance!.Configuration.WithChromaprint && !ffmpegValid)
if (Plugin.Instance!.Configuration.WithChromaprint && !FFmpegWrapper.CheckFFmpegVersion())
{
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(
@ -122,11 +124,8 @@ public class BaseItemAnalyzerTask
Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly
progress.Report(totalProcessed * 100 / totalQueued);
return;
}
if (_analysisModes.Count != requiredModes.Count)
else if (_analysisModes.Count != requiredModes.Count)
{
Interlocked.Add(ref totalProcessed, episodes.Count);
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.
var first = items[0];
if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
if (!first.IsMovie && first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
{
return 0;
}
@ -211,7 +210,7 @@ public class BaseItemAnalyzerTask
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>()));
}
@ -221,7 +220,7 @@ public class BaseItemAnalyzerTask
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>()));
}

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.Collections.Generic;
using System.IO;
@ -8,7 +11,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
namespace IntroSkipper.ScheduledTasks;
/// <summary>
/// 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.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Data;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
namespace IntroSkipper.ScheduledTasks;
/// <summary>
/// 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.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Data;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
namespace IntroSkipper.ScheduledTasks;
/// <summary>
/// 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.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using IntroSkipper.Data;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
namespace IntroSkipper.ScheduledTasks;
/// <summary>
/// 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.Threading;
namespace ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
namespace IntroSkipper.ScheduledTasks;
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.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using IntroSkipper.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
@ -15,7 +18,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
namespace IntroSkipper;
/// <summary>
/// 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.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using IntroSkipper.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
@ -15,7 +18,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
namespace IntroSkipper;
/// <summary>
/// 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.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using ConfusedPolarBear.Plugin.IntroSkipper.ScheduledTasks;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using IntroSkipper.ScheduledTasks;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
@ -13,7 +16,7 @@ using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
namespace IntroSkipper;
/// <summary>
/// Server entrypoint.

View File

@ -9,10 +9,14 @@
</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)
https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/manifest.json
</div>
## Manifest URL (All Jellyfin Versions)
```
https://manifest.intro-skipper.org/manifest.json
```
## System requirements
* 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.
## Jellyfin 10.8 (previous version)
👉👉👉 [Jellyfin 10.8 Instructions](https://github.com/intro-skipper/intro-skipper/blob/10.8/README.md)
## [Detection parameters](https://github.com/intro-skipper/intro-skipper/wiki#detection-parameters)
## Detection parameters
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
## [Detection types](https://github.com/intro-skipper/intro-skipper/wiki#detection-types)
## [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)
- #### [Custom FFMPEG (MacOS)](https://github.com/intro-skipper/intro-skipper/wiki/Custom-FFMPEG-(MacOS))
## [Jellyfin Skip Options](https://github.com/intro-skipper/intro-skipper/wiki/Jellyfin-Skip-Options)
## [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)

1
VERSION.txt Normal file
View File

@ -0,0 +1 @@
10.9

View File

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

View File

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

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