docker-mod

This commit is contained in:
rlauu 2024-06-12 12:56:30 +02:00
parent c9f87c58cf
commit 6f4b1e118e
110 changed files with 100 additions and 11095 deletions

View File

@ -1,194 +0,0 @@
# With more recent updates Visual Studio 2017 supports EditorConfig files out of the box
# Visual Studio Code needs an extension: https://github.com/editorconfig/editorconfig-vscode
# For emacs, vim, np++ and other editors, see here: https://github.com/editorconfig
###############################
# Core EditorConfig Options #
###############################
root = true
# All files
[*]
indent_style = space
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf
max_line_length = off
# YAML indentation
[*.{yml,yaml}]
indent_size = 2
# XML indentation
[*.{csproj,xml}]
indent_size = 2
###############################
# .NET Coding Conventions #
###############################
[*.{cs,vb}]
# Organize usings
dotnet_sort_system_directives_first = true
# this. preferences
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_property = false:silent
dotnet_style_qualification_for_method = false:silent
dotnet_style_qualification_for_event = false:silent
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_predefined_type_for_member_access = true:silent
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
dotnet_style_readonly_field = true:suggestion
# Expression-level preferences
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
###############################
# Naming Conventions #
###############################
# Style Definitions (From Roslyn)
# Non-private static fields are PascalCase
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style
dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field
dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
dotnet_naming_symbols.non_private_static_fields.required_modifiers = static
dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case
# Constants are PascalCase
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style
dotnet_naming_symbols.constants.applicable_kinds = field, local
dotnet_naming_symbols.constants.required_modifiers = const
dotnet_naming_style.constant_style.capitalization = pascal_case
# Static fields are camelCase and start with s_
dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion
dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields
dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style
dotnet_naming_symbols.static_fields.applicable_kinds = field
dotnet_naming_symbols.static_fields.required_modifiers = static
dotnet_naming_style.static_field_style.capitalization = camel_case
dotnet_naming_style.static_field_style.required_prefix = _
# Instance fields are camelCase and start with _
dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion
dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields
dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style
dotnet_naming_symbols.instance_fields.applicable_kinds = field
dotnet_naming_style.instance_field_style.capitalization = camel_case
dotnet_naming_style.instance_field_style.required_prefix = _
# Locals and parameters are camelCase
dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion
dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters
dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style
dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local
dotnet_naming_style.camel_case_style.capitalization = camel_case
# Local functions are PascalCase
dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions
dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
dotnet_naming_style.local_function_style.capitalization = pascal_case
# By default, name items with PascalCase
dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members
dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style
dotnet_naming_symbols.all_members.applicable_kinds = *
dotnet_naming_style.pascal_case_style.capitalization = pascal_case
###############################
# C# Coding Conventions #
###############################
[*.cs]
# var preferences
csharp_style_var_for_built_in_types = true:silent
csharp_style_var_when_type_is_apparent = true:silent
csharp_style_var_elsewhere = true:silent
# Expression-bodied members
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_accessors = true:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
# Null-checking preferences
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences
csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion
# Expression-level preferences
csharp_prefer_braces = true:silent
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_pattern_local_over_anonymous_function = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
###############################
# C# Formatting Rules #
###############################
# New line preferences
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
csharp_indent_labels = flush_left
# Space preferences
csharp_space_after_cast = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_around_binary_operators = before_and_after
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
# Wrapping preferences
csharp_preserve_single_line_statements = true
csharp_preserve_single_line_blocks = true

View File

@ -1,77 +0,0 @@
name: "Bug report"
description: "Create a report to help us improve"
title: "[Bug]: "
labels: [bug]
body:
- type: checkboxes
id: requirements
attributes:
label: Self service debugging
description: |
Jellyfin 10.9 is still being actively updated. Please make sure you are using the newest release
Docker containers have known permission issues that can be resolved with a few extra steps.
If your skip button is not shown, please see https://github.com/jumoog/intro-skipper/issues/104
options:
- label: Jellyfin is updated and my permissions are correct (or I did not use Docker)
required: true
- type: textarea
attributes:
label: Describe the bug
description: Also tell us, what did you expect to happen?
placeholder: |
The more information that you are able to provide, the better. Did you do anything before this happened? Did you upgrade or change anything? Any screenshots or logs you can provide will be helpful.
This is my issue.
Steps to Reproduce
1. In this environment...
2. With this config...
3. Run '...'
4. See error...
validations:
required: true
- type: input
attributes:
label: Jellyfin install method
description: How you installed Jellyfin or the tool used to install it
placeholder: Docker, Windows installer, etc.
validations:
required: true
- type: input
attributes:
label: Container image/tag or Jellyfin version
description: The container for Docker or Jellyfin version for a native install
placeholder: jellyfin/jellyfin:10.8.7, jellyfin-intro-skipper:latest, etc.
validations:
required: true
- type: input
attributes:
label: Operating System
description: The operating system of the Jellyfin / Docker host computer
placeholder: Debian 11, Windows 11, etc.
validations:
required: true
- type: input
attributes:
label: IMDb ID of that TV Series
placeholder: tt0903747
- type: textarea
attributes:
label: Support Bundle
placeholder: go to Dashboard -> Plugins -> Intro Skipper -> Support Bundle (at the bottom of the page) and paste the contents of the textbox here
render: shell
validations:
required: true
- type: textarea
attributes:
label: Jellyfin logs
placeholder: Paste any relevant logs here
render: shell

View File

@ -1,29 +0,0 @@
version: 2
updates:
# Fetch and update latest `nuget` pkgs
- package-ecosystem: nuget
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
labels:
- chore
- dependency
- nuget
commit-message:
prefix: chore
include: scope
# Fetch and update latest `github-actions` pkgs
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 10
labels:
- ci
- dependency
- github_actions
commit-message:
prefix: ci
include: scope

61
.github/workflows/BuildImage.yml vendored Normal file
View File

@ -0,0 +1,61 @@
name: Build Image
on: [push, pull_request, workflow_dispatch]
env:
ENDPOINT: "jumoog/intro-skipper"
BRANCH: "docker-mod"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2.3.3
- name: Build image
run: |
docker build --no-cache -t ${{ github.sha }} .
- name: Tag image
if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.ENDPOINT != 'user/endpoint' }}
run: |
docker tag ${{ github.sha }} ${ENDPOINT}
docker tag ${{ github.sha }} ${ENDPOINT}:${{ github.sha }}
docker tag ${{ github.sha }} ghcr.io/${ENDPOINT}
docker tag ${{ github.sha }} ghcr.io/${ENDPOINT}:${{ github.sha }}
- name: Credential check
if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.ENDPOINT != 'user/endpoint' }}
run: |
echo "CR_USER=${{ secrets.CR_USER }}" >> $GITHUB_ENV
echo "CR_PAT=${{ secrets.CR_PAT }}" >> $GITHUB_ENV
echo "DOCKERUSER=${{ secrets.DOCKERUSER }}" >> $GITHUB_ENV
echo "DOCKERPASS=${{ secrets.DOCKERPASS }}" >> $GITHUB_ENV
if [[ "${{ secrets.CR_USER }}" == "" && "${{ secrets.CR_PAT }}" == "" && "${{ secrets.DOCKERUSER }}" == "" && "${{ secrets.DOCKERPASS }}" == "" ]]; then
echo "::error::Push credential secrets missing."
echo "::error::You must set either CR_USER & CR_PAT or DOCKERUSER & DOCKERPASS as secrets in your repo settings."
echo "::error::See https://github.com/linuxserver/docker-mods/blob/master/README.md for more information/instructions."
exit 1
fi
- name: Login to GitHub Container Registry
if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.CR_USER && env.CR_PAT && env.ENDPOINT != 'user/endpoint' }}
run: |
echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ secrets.CR_USER }} --password-stdin
- name: Push tags to GitHub Container Registry
if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.CR_USER && env.CR_PAT && env.ENDPOINT != 'user/endpoint' }}
run: |
docker push ghcr.io/${ENDPOINT}:${{ github.sha }}
docker push ghcr.io/${ENDPOINT}
- name: Login to DockerHub
if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.DOCKERUSER && env.DOCKERPASS && env.ENDPOINT != 'user/endpoint' }}
run: |
echo ${{ secrets.DOCKERPASS }} | docker login -u ${{ secrets.DOCKERUSER }} --password-stdin
- name: Push tags to DockerHub
if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.DOCKERUSER && env.DOCKERPASS && env.ENDPOINT != 'user/endpoint' }}
run: |
docker push ${ENDPOINT}:${{ github.sha }}
docker push ${ENDPOINT}

View File

@ -1,87 +0,0 @@
name: 'Build Plugin'
on:
push:
branches: [ "master" ]
paths-ignore:
- '**/README.md'
- '.github/ISSUE_TEMPLATE/**'
- 'docs/**'
- 'images/**'
- 'manifest.json'
pull_request:
branches: [ "master" ]
paths-ignore:
- '**/README.md'
- '.github/ISSUE_TEMPLATE/**'
- 'docs/**'
- 'images/**'
- 'manifest.json'
permissions:
contents: write
packages: write
jobs:
build:
if: ${{ ! startsWith(github.event.head_commit.message, 'v0.') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
- name: Embed version info
run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt
- name: Retrieve commit identification
run: |
GIT_HASH=$(git rev-parse --short HEAD)
echo "GIT_HASH=${GIT_HASH}" >> $GITHUB_ENV
- name: Build
run: dotnet build --no-restore
- name: Upload artifact
uses: actions/upload-artifact@v4.3.3
if: github.event_name != 'pull_request'
with:
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.GIT_HASH }}.dll
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
if-no-files-found: error
- name: Upload artifact
uses: actions/upload-artifact@v4.3.3
if: github.event_name == 'pull_request'
with:
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ github.head_ref }}.dll
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
retention-days: 7
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
- name: Generate md5
if: github.event_name != 'pull_request'
run: |
md5sum intro-skipper-${{ env.GIT_HASH }}.zip > intro-skipper-${{ env.GIT_HASH }}.md5
checksum="$(awk '{print $1}' intro-skipper-${{ env.GIT_HASH }}.md5)"
echo "CHECKSUM=$checksum" >> $GITHUB_ENV
- name: Create/replace the preview release and upload artifacts
if: github.event_name != 'pull_request'
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."
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -0,0 +1,16 @@
name: Issue & PR Tracker
on:
issues:
types: [opened,reopened,labeled,unlabeled,closed]
pull_request_target:
types: [opened,reopened,review_requested,review_request_removed,labeled,unlabeled,closed]
pull_request_review:
types: [submitted,edited,dismissed]
jobs:
manage-project:
permissions:
issues: write
uses: linuxserver/github-workflows/.github/workflows/issue-pr-tracker.yml@v1
secrets: inherit

View File

@ -1,55 +0,0 @@
name: "CodeQL"
on:
push:
branches: [ master ]
paths-ignore:
- '**/README.md'
- '.github/ISSUE_TEMPLATE/**'
- 'docs/**'
- 'images/**'
- 'manifest.json'
pull_request:
branches: [ master ]
paths-ignore:
- '**/README.md'
- '.github/ISSUE_TEMPLATE/**'
- 'docs/**'
- 'images/**'
- 'manifest.json'
permissions: write-all
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'csharp' ]
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install dependencies
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: Initialize CodeQL
uses: github/codeql-action/init@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8

10
.github/workflows/permissions.yml vendored Normal file
View File

@ -0,0 +1,10 @@
name: Permission check
on:
pull_request_target:
paths:
- '**/run'
- '**/finish'
- '**/check'
jobs:
permission_check:
uses: linuxserver/github-workflows/.github/workflows/init-svc-executable-permissions.yml@v1

View File

@ -1,77 +0,0 @@
name: 'Release Plugin'
on:
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
- name: Run update version
run: node update-version.js
- name: Embed version info
run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt
- name: Build
run: dotnet build --no-restore
- name: Upload artifact
uses: actions/upload-artifact@v4.3.3
with:
name: ConfusedPolarBear.Plugin.IntroSkipper-v${{ env.NEW_FILE_VERSION }}.dll
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
if-no-files-found: error
- name: Create archive
run: zip -j "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
- name: Generate manifest keys
run: |
sourceUrl="https://github.com/${{ github.repository }}/releases/download/10.9/v${{ env.NEW_FILE_VERSION }}/intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip"
echo "SOURCE_URL=$sourceUrl" >> $GITHUB_ENV
md5sum intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip > intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5
checksum="$(awk '{print $1}' intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5)"
echo "CHECKSUM=$checksum" >> $GITHUB_ENV
timestamp="$(date +%FT%TZ)"
echo "TIMESTAMP=$timestamp" >> $GITHUB_ENV
- name: 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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run validation and update script
run: node validate-and-update-manifest.js
env:
VERSION: ${{ env.NEW_FILE_VERSION }}
- name: Commit changes
if: success()
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add manifest.json ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj
git commit -m "release v${{ env.NEW_FILE_VERSION }}"
git push

10
.gitignore vendored
View File

@ -1,10 +0,0 @@
bin/
obj/
BenchmarkDotNet.Artifacts/
/package/
# Ignore pre compiled web interface
docker/dist
# Visual Studio
.vs/

View File

@ -1,7 +0,0 @@
Intro Skipper is made possible by the following open source projects:
* [acoustid-match](https://github.com/dnknth/acoustid-match) (MIT)
* [chromaprint](https://github.com/acoustid/chromaprint) (LGPL 2.1)
* [JellyScrub](https://github.com/nicknsy/jellyscrub) (MIT)
* [Jellyfin](https://github.com/jellyfin/jellyfin) (GPL)
* [Jellyfin Media Analyzer](https://github.com/endrl/jellyfin-plugin-media-analyzer) (GPL)

View File

@ -1,93 +0,0 @@
# Changelog
## v0.1.8.0 (no eta)
* New features
* Support adding skip intro button to web interface without using a fork
* Add localization support for the skip intro button and the automatic skip notification message
* Detect ending credits in television episodes
* Add support for using chapter names to locate introductions and ending credits
* Add support for using black frames to locate ending credits
* Show skip button when on screen controls are visible (#149 by @DualScorch)
* Internal changes
* Move Chromaprint analysis code out of the episode analysis task
* Add support for multiple analysis techinques
## v0.1.7.0 (2022-10-26)
* New features
* Rewrote fingerprint comparison algorithm to be faster (~30x speedup) and detect more introductions
* Detect silence at the end of introductions and use it to avoid skipping over the beginning of an episode
* If you are upgrading from a previous release and want to use the silence detection feature on shows that have already been analyzed, you must click the `Erase introduction timestamps` button at the bottom of the plugin settings page
* Add support bundle
* Add maximum introduction duration
* Support playing a few seconds from the end of the introduction to verify that no episode content was skipped over
* Amount played is customizable and defaults to 2 seconds
* Support modifying introduction detection algorithm settings
* Add option to not skip the introduction in the first episode of a season
* Add option to analyze show extras (specials)
* Fixes
* Fix scheduled task interval (#79)
* Prevent show names from becoming duplicated in the show name dropdown under the advanced section
* Prevent virtual episodes from being inserted into the analysis queue
## v0.1.6.0 (2022-08-04)
* New features
* Generate EDL files with intro timestamps ([documentation](docs/edl.md)) (#21)
* Support selecting which libraries are analyzed (#37)
* Support customizing [introduction requirements](README.md#introduction-requirements) (#38, #51)
* Changing these settings will increase episode analysis times
* Support adding and editing intro timestamps (#26)
* Report how CPU time is being spent while analyzing episodes
* CPU time reports can be viewed under "Analysis Statistics (experimental)" in the plugin configuration page
* Sped up fingerprint analysis (not including fingerprint generation time) by 40%
* Support erasing discovered introductions by season
* Suggest potential shifts in the fingerprint visualizer
* Fixes
* Ensure episode analysis queue matches the current filesystem and library state (#42, #60)
* Fixes a bug where renamed or deleted episodes were being analyzed
* Fix automatic intro skipping on Android TV (#57, #61)
* Restore per season status updates in the log
* Prevent null key in `/Intros/Shows` endpoint (#27)
* Fix positioning of skip intro button on mobile devices (#43)
* Ensure video playback always resumes after clicking the skip intro button (#44)
## v0.1.5.0 (2022-06-17)
* Use `ffmpeg` to generate audio fingerprints instead of `fpcalc`
* Requires that the installed version of `ffmpeg`:
* Was compiled with the `--enable-chromaprint` option
* Understands the `-fp_format raw` flag
* `jellyfin-ffmpeg 5.0.1-5` meets both of these requirements
* Version API endpoints
* See [api.md](docs/api.md) for detailed documentation on how clients can work with this plugin
* Add commit hash to unstable builds
* Log media paths that are unable to be fingerprinted
* Report failure to the UI if the episode analysis queue is empty
* Allow customizing degrees of parallelism
* Warning: Using a value that is too high will result in system instability
* Remove restart requirement to change auto skip setting
* Rewrite startup enqueue
* Fix deadlock issue on Windows (#23 by @nyanmisaka)
* Improve skip intro button styling & positioning (ConfusedPolarBear/jellyfin-web#91 by @Fallenbagel)
* Order episodes by `IndexNumber` (#25 reported by @Flo56958)
## v0.1.0.0 (2022-06-09)
* Add option to automatically skip intros
* Cache audio fingerprints by default
* Add fingerprint visualizer
* Add button to erase all previously discovered intro timestamps
* Made saving settings more reliable
* Switch to new fingerprint comparison algorithm
* If you would like to test the new comparison algorithm, you will have to erase all previously discovered introduction timestamps.
## v0.0.0.3 (2022-05-21)
* Fix `fpcalc` version check
## v0.0.0.2 (2022-05-21)
* Analyze multiple seasons in parallel
* Reanalyze episodes with an unusually short or long intro sequence
* Check installed `fpcalc` version
* Clarify installation instructions
## v0.0.0.1 (2022-05-10)
* First alpha build

View File

@ -1,27 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj" />
</ItemGroup>
</Project>

View File

@ -1,162 +0,0 @@
/* 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 Xunit;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
public class TestAudioFingerprinting
{
[FactSkipFFmpegTests]
public void TestInstallationCheck()
{
Assert.True(FFmpegWrapper.CheckFFmpegVersion());
}
[Theory]
[InlineData(0, 0)]
[InlineData(1, 1)]
[InlineData(5, 213)]
[InlineData(10, 56_021)]
[InlineData(16, 16_112_341)]
[InlineData(19, 2_465_585_877)]
public void TestBitCounting(int expectedBits, uint number)
{
var chromaprint = CreateChromaprintAnalyzer();
Assert.Equal(expectedBits, chromaprint.CountBits(number));
}
[FactSkipFFmpegTests]
public void TestFingerprinting()
{
// Generated with `fpcalc -raw audio/big_buck_bunny_intro.mp3`
var expected = new uint[]{
3269995649, 3261610160, 3257403872, 1109989680, 1109993760, 1110010656, 1110142768, 1110175504,
1110109952, 1126874880, 2788611, 2787586, 6981634, 15304754, 28891170, 43579426, 43542561,
47737888, 41608640, 40559296, 36352644, 53117572, 2851460, 1076465548, 1080662428, 1080662492,
1089182044, 1148041501, 1148037422, 3291343918, 3290980398, 3429367854, 3437756714, 3433698090,
3433706282, 3366600490, 3366464314, 2296916250, 3362269210, 3362265115, 3362266441, 3370784472,
3366605480, 1218990776, 1223217816, 1231602328, 1260950200, 1245491640, 169845176, 1510908120,
1510911000, 2114365528, 2114370008, 1996929688, 1996921480, 1897171592, 1884588680, 1347470984,
1343427226, 1345467054, 1349657318, 1348673570, 1356869666, 1356865570, 295837698, 60957698,
44194818, 48416770, 40011778, 36944210, 303147954, 369146786, 1463847842, 1434488738, 1417709474,
1417713570, 3699441634, 3712167202, 3741460534, 2585144342, 2597725238, 2596200487, 2595926077,
2595984141, 2594734600, 2594736648, 2598931176, 2586348264, 2586348264, 2586561257, 2586451659,
2603225802, 2603225930, 2573860970, 2561151018, 3634901034, 3634896954, 3651674122, 3416793162,
3416816715, 3404331257, 3395844345, 3395836155, 3408464089, 3374975369, 1282036360, 1290457736,
1290400440, 1290314408, 1281925800, 1277727404, 1277792932, 1278785460, 1561962388, 1426698196,
3607924711, 4131892839, 4140215815, 4292259591, 3218515717, 3209938229, 3171964197, 3171956013,
4229117295, 4229312879, 4242407935, 4240114111, 4239987133, 4239990013, 3703060732, 1547188252,
1278748677, 1278748935, 1144662786, 1148854786, 1090388802, 1090388962, 1086260130, 1085940098,
1102709122, 45811586, 44634002, 44596656, 44592544, 1122527648, 1109944736, 1109977504, 1111030243,
1111017762, 1109969186, 1126721826, 1101556002, 1084844322, 1084979506, 1084914450, 1084914449,
1084873520, 3228093296, 3224996817, 3225062275, 3241840002, 3346701698, 3349843394, 3349782306,
3349719842, 3353914146, 3328748322, 3328747810, 3328809266, 3471476754, 3472530451, 3472473123,
3472417825, 3395841056, 3458735136, 3341420624, 1076496560, 1076501168, 1076501136, 1076497024
};
var actual = FFmpegWrapper.Fingerprint(
queueEpisode("audio/big_buck_bunny_intro.mp3"),
AnalysisMode.Introduction);
Assert.Equal(expected, actual);
}
[Fact]
public void TestIndexGeneration()
{
// 0 1 2 3 4 5 6 7
var fpr = new uint[] { 1, 2, 3, 1, 5, 77, 42, 2 };
var expected = new Dictionary<uint, int>()
{
{1, 3},
{2, 7},
{3, 2},
{5, 4},
{42, 6},
{77, 5},
};
var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction);
Assert.Equal(expected, actual);
}
[FactSkipFFmpegTests]
public void TestIntroDetection()
{
var chromaprint = CreateChromaprintAnalyzer();
var lhsEpisode = queueEpisode("audio/big_buck_bunny_intro.mp3");
var rhsEpisode = queueEpisode("audio/big_buck_bunny_clip.mp3");
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction);
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction);
var (lhs, rhs) = chromaprint.CompareEpisodes(
lhsEpisode.EpisodeId,
lhsFingerprint,
rhsEpisode.EpisodeId,
rhsFingerprint);
Assert.True(lhs.Valid);
Assert.Equal(0, lhs.IntroStart);
Assert.Equal(17.208, lhs.IntroEnd, 3);
Assert.True(rhs.Valid);
// because we changed for 0.128 to 0.1238 its 4,952 now but that's too early (<= 5)
Assert.Equal(0, rhs.IntroStart);
Assert.Equal(22.1602, rhs.IntroEnd);
}
/// <summary>
/// Test that the silencedetect wrapper is working.
/// </summary>
[FactSkipFFmpegTests]
public void TestSilenceDetection()
{
var clip = queueEpisode("audio/big_buck_bunny_clip.mp3");
var expected = new TimeRange[]
{
new TimeRange(44.6310, 44.8072),
new TimeRange(53.5905, 53.8070),
new TimeRange(53.8458, 54.2024),
new TimeRange(54.2611, 54.5935),
new TimeRange(54.7098, 54.9293),
new TimeRange(54.9294, 55.2590),
};
var actual = FFmpegWrapper.DetectSilence(clip, 60);
Assert.Equal(expected, actual);
}
private QueuedEpisode queueEpisode(string path)
{
return new QueuedEpisode()
{
EpisodeId = Guid.NewGuid(),
Path = "../../../" + path,
IntroFingerprintEnd = 60
};
}
private ChromaprintAnalyzer CreateChromaprintAnalyzer()
{
var logger = new LoggerFactory().CreateLogger<ChromaprintAnalyzer>();
return new(logger);
}
}
public class FactSkipFFmpegTests : FactAttribute
{
#if SKIP_FFMPEG_TESTS
public FactSkipFFmpegTests() {
Skip = "SKIP_FFMPEG_TESTS defined, skipping unit tests that require FFmpeg to be installed";
}
#endif
}

View File

@ -1,73 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using Xunit;
public class TestBlackFrames
{
[FactSkipFFmpegTests]
public void TestBlackFrameDetection()
{
var range = 1e-5;
var expected = new List<BlackFrame>();
expected.AddRange(CreateFrameSequence(2.04, 3));
expected.AddRange(CreateFrameSequence(5, 6));
expected.AddRange(CreateFrameSequence(8, 9.96));
var actual = FFmpegWrapper.DetectBlackFrames(queueFile("rainbow.mp4"), new(0, 10), 85);
for (var i = 0; i < expected.Count; i++)
{
var (e, a) = (expected[i], actual[i]);
Assert.Equal(e.Percentage, a.Percentage);
Assert.InRange(a.Time, e.Time - range, e.Time + range);
}
}
[FactSkipFFmpegTests]
public void TestEndCreditDetection()
{
// new strategy new range
var range = 3;
var analyzer = CreateBlackFrameAnalyzer();
var episode = queueFile("credits.mp4");
episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds;
var result = analyzer.AnalyzeMediaFile(episode, 240, 30, 85);
Assert.NotNull(result);
Assert.InRange(result.IntroStart, 300 - range, 300 + range);
}
private QueuedEpisode queueFile(string path)
{
return new()
{
EpisodeId = Guid.NewGuid(),
Name = path,
Path = "../../../video/" + path
};
}
private BlackFrame[] CreateFrameSequence(double start, double end)
{
var frames = new List<BlackFrame>();
for (var i = start; i < end; i += 0.04)
{
frames.Add(new(100, i));
}
return frames.ToArray();
}
private BlackFrameAnalyzer CreateBlackFrameAnalyzer()
{
var logger = new LoggerFactory().CreateLogger<BlackFrameAnalyzer>();
return new(logger);
}
}

View File

@ -1,83 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
using Xunit;
public class TestChapterAnalyzer
{
[Theory]
[InlineData("Opening")]
[InlineData("OP")]
[InlineData("Intro")]
[InlineData("Intro Start")]
[InlineData("Introduction")]
public void TestIntroductionExpression(string chapterName)
{
var chapters = CreateChapters(chapterName, AnalysisMode.Introduction);
var introChapter = FindChapter(chapters, AnalysisMode.Introduction);
Assert.NotNull(introChapter);
Assert.Equal(60, introChapter.IntroStart);
Assert.Equal(90, introChapter.IntroEnd);
}
[Theory]
[InlineData("End Credits")]
[InlineData("Ending")]
[InlineData("Credit start")]
[InlineData("Closing Credits")]
[InlineData("Credits")]
public void TestEndCreditsExpression(string chapterName)
{
var chapters = CreateChapters(chapterName, AnalysisMode.Credits);
var creditsChapter = FindChapter(chapters, AnalysisMode.Credits);
Assert.NotNull(creditsChapter);
Assert.Equal(1890, creditsChapter.IntroStart);
Assert.Equal(2000, creditsChapter.IntroEnd);
}
private Intro? FindChapter(Collection<ChapterInfo> chapters, AnalysisMode mode)
{
var logger = new LoggerFactory().CreateLogger<ChapterAnalyzer>();
var analyzer = new ChapterAnalyzer(logger);
var config = new Configuration.PluginConfiguration();
var expression = mode == AnalysisMode.Introduction ?
config.ChapterAnalyzerIntroductionPattern :
config.ChapterAnalyzerEndCreditsPattern;
return analyzer.FindMatchingChapter(new() { Duration = 2000 }, chapters, expression, mode);
}
private Collection<ChapterInfo> CreateChapters(string name, AnalysisMode mode)
{
var chapters = new[]{
CreateChapter("Cold Open", 0),
CreateChapter(mode == AnalysisMode.Introduction ? name : "Introduction", 60),
CreateChapter("Main Episode", 90),
CreateChapter(mode == AnalysisMode.Credits ? name : "Credits", 1890)
};
return new(new List<ChapterInfo>(chapters));
}
/// <summary>
/// Create a ChapterInfo object.
/// </summary>
/// <param name="name">Chapter name.</param>
/// <param name="position">Chapter position (in seconds).</param>
/// <returns>ChapterInfo.</returns>
private ChapterInfo CreateChapter(string name, int position)
{
return new()
{
Name = name,
StartPositionTicks = TimeSpan.FromSeconds(position).Ticks
};
}
}

View File

@ -1,92 +0,0 @@
using Xunit;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
public class TestTimeRanges
{
[Fact]
public void TestSmallRange()
{
var times = new double[]{
1, 1.5, 2, 2.5, 3, 3.5, 4,
100, 100.5, 101, 101.5
};
var expected = new TimeRange(1, 4);
var actual = TimeRangeHelpers.FindContiguous(times, 2);
Assert.Equal(expected, actual);
}
[Fact]
public void TestLargeRange()
{
var times = new double[]{
1, 1.5, 2,
2.8, 2.9, 2.995, 3.0, 3.01, 3.02, 3.4, 3.45, 3.48, 3.7, 3.77, 3.78, 3.781, 3.782, 3.789, 3.85,
4.5, 5.3122, 5.3123, 5.3124, 5.3125, 5.3126, 5.3127, 5.3128,
55, 55.5, 55.6, 55.7
};
var expected = new TimeRange(1, 5.3128);
var actual = TimeRangeHelpers.FindContiguous(times, 2);
Assert.Equal(expected, actual);
}
[Fact]
public void TestFuturama()
{
// These timestamps were manually extracted from Futurama S01E04 and S01E05.
var times = new double[]{
2.176, 8.32, 10.112, 11.264, 13.696, 16, 16.128, 16.64, 16.768, 16.896, 17.024, 17.152, 17.28,
17.408, 17.536, 17.664, 17.792, 17.92, 18.048, 18.176, 18.304, 18.432, 18.56, 18.688, 18.816,
18.944, 19.072, 19.2, 19.328, 19.456, 19.584, 19.712, 19.84, 19.968, 20.096, 20.224, 20.352,
20.48, 20.608, 20.736, 20.864, 20.992, 21.12, 21.248, 21.376, 21.504, 21.632, 21.76, 21.888,
22.016, 22.144, 22.272, 22.4, 22.528, 22.656, 22.784, 22.912, 23.04, 23.168, 23.296, 23.424,
23.552, 23.68, 23.808, 23.936, 24.064, 24.192, 24.32, 24.448, 24.576, 24.704, 24.832, 24.96,
25.088, 25.216, 25.344, 25.472, 25.6, 25.728, 25.856, 25.984, 26.112, 26.24, 26.368, 26.496,
26.624, 26.752, 26.88, 27.008, 27.136, 27.264, 27.392, 27.52, 27.648, 27.776, 27.904, 28.032,
28.16, 28.288, 28.416, 28.544, 28.672, 28.8, 28.928, 29.056, 29.184, 29.312, 29.44, 29.568,
29.696, 29.824, 29.952, 30.08, 30.208, 30.336, 30.464, 30.592, 30.72, 30.848, 30.976, 31.104,
31.232, 31.36, 31.488, 31.616, 31.744, 31.872, 32, 32.128, 32.256, 32.384, 32.512, 32.64,
32.768, 32.896, 33.024, 33.152, 33.28, 33.408, 33.536, 33.664, 33.792, 33.92, 34.048, 34.176,
34.304, 34.432, 34.56, 34.688, 34.816, 34.944, 35.072, 35.2, 35.328, 35.456, 35.584, 35.712,
35.84, 35.968, 36.096, 36.224, 36.352, 36.48, 36.608, 36.736, 36.864, 36.992, 37.12, 37.248,
37.376, 37.504, 37.632, 37.76, 37.888, 38.016, 38.144, 38.272, 38.4, 38.528, 38.656, 38.784,
38.912, 39.04, 39.168, 39.296, 39.424, 39.552, 39.68, 39.808, 39.936, 40.064, 40.192, 40.32,
40.448, 40.576, 40.704, 40.832, 40.96, 41.088, 41.216, 41.344, 41.472, 41.6, 41.728, 41.856,
41.984, 42.112, 42.24, 42.368, 42.496, 42.624, 42.752, 42.88, 43.008, 43.136, 43.264, 43.392,
43.52, 43.648, 43.776, 43.904, 44.032, 44.16, 44.288, 44.416, 44.544, 44.672, 44.8, 44.928,
45.056, 45.184, 57.344, 62.976, 68.864, 74.368, 81.92, 82.048, 86.528, 100.864, 102.656,
102.784, 102.912, 103.808, 110.976, 116.864, 125.696, 128.384, 133.248, 133.376, 136.064,
136.704, 142.976, 150.272, 152.064, 164.864, 164.992, 166.144, 166.272, 175.488, 190.08,
191.872, 192, 193.28, 193.536, 213.376, 213.504, 225.664, 225.792, 243.2, 243.84, 256,
264.448, 264.576, 264.704, 269.568, 274.816, 274.944, 276.096, 283.264, 294.784, 294.912,
295.04, 295.168, 313.984, 325.504, 333.568, 335.872, 336.384
};
var expected = new TimeRange(16, 45.184);
var actual = TimeRangeHelpers.FindContiguous(times, 2);
Assert.Equal(expected, actual);
}
/// <summary>
/// Tests that TimeRange intersections are detected correctly.
/// Tests each time range against a range of 5 to 10 seconds.
/// </summary>
[Theory]
[InlineData(1, 4, false)] // too early
[InlineData(4, 6, true)] // intersects on the left
[InlineData(7, 8, true)] // in the middle
[InlineData(9, 12, true)] // intersects on the right
[InlineData(13, 15, false)] // too late
public void TestTimeRangeIntersection(int start, int end, bool expected)
{
var large = new TimeRange(5, 10);
var testRange = new TimeRange(start, end);
Assert.Equal(expected, large.Intersects(testRange));
}
}

View File

@ -1,44 +0,0 @@
using System;
using Xunit;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
public class TestEdl
{
// Test data is from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL
[Theory]
[InlineData(5.3, 7.1, EdlAction.Cut, "5.3 7.1 0")]
[InlineData(15, 16.7, EdlAction.Mute, "15 16.7 1")]
[InlineData(420, 822, EdlAction.CommercialBreak, "420 822 3")]
[InlineData(1, 255.3, EdlAction.SceneMarker, "1 255.3 2")]
[InlineData(1.123456789, 5.654647987, EdlAction.CommercialBreak, "1.12 5.65 3")]
public void TestEdlSerialization(double start, double end, EdlAction action, string expected)
{
var intro = MakeIntro(start, end);
var actual = intro.ToEdl(action);
Assert.Equal(expected, actual);
}
[Fact]
public void TestEdlInvalidSerialization()
{
Assert.Throws<ArgumentException>(() => {
var intro = MakeIntro(0, 5);
intro.ToEdl(EdlAction.None);
});
}
[Theory]
[InlineData("Death Note - S01E12 - Love.mkv", "Death Note - S01E12 - Love.edl")]
[InlineData("/full/path/to/file.rm", "/full/path/to/file.edl")]
public void TestEdlPath(string mediaPath, string edlPath)
{
Assert.Equal(edlPath, EdlManager.GetEdlPath(mediaPath));
}
private Intro MakeIntro(double start, double end)
{
return new Intro(Guid.Empty, new TimeRange(start, end));
}
}

View File

@ -1,34 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using Xunit;
public class TestFlags
{
[Fact]
public void TestEmptyFlagSerialization()
{
WarningManager.Clear();
Assert.Equal("None", WarningManager.GetWarnings());
}
[Fact]
public void TestSingleFlagSerialization()
{
WarningManager.Clear();
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
Assert.Equal("UnableToAddSkipButton", WarningManager.GetWarnings());
}
[Fact]
public void TestDoubleFlagSerialization()
{
WarningManager.Clear();
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
Assert.Equal(
"UnableToAddSkipButton, InvalidChromaprintFingerprint",
WarningManager.GetWarnings());
}
}

View File

@ -1,7 +0,0 @@
The audio used in the fingerprinting unit tests is from Big Buck Bunny, attributed below.
Both big_buck_bunny_intro.mp3 and big_buck_bunny_clip.mp3 are derived from Big Buck Bunny, (c) copyright 2008, Blender Foundation / www.bigbuckbunny.org. They are used under the Creative Commons Attribution 3.0 and the original source can be found at https://www.youtube.com/watch?v=YE7VzlLtp-4.
Both files have been downmixed to two audio channels.
big_buck_bunny_intro.mp3 is from 5 to 30 seconds.
big_buck_bunny_clip.mp3 is from 0 to 60 seconds.

View File

@ -1,14 +0,0 @@
# Binaries
/verifier/verifier
/run_tests
/plugin_binaries/
# Wrapper configuration and base configuration files
config.json
/config/
# Timestamp reports
/reports/
# Selenium screenshots
selenium/screenshots/

View File

@ -1,52 +0,0 @@
# End to end testing framework
## wrapper
The wrapper script (compiled as `run_tests`) runs multiple tests on Jellyfin servers to verify that the plugin works as intended. It tests:
- Introduction timestamp accuracy (using `verifier`)
- Web interface functionality (using `selenium/main.py`)
## verifier
### Description
This program is responsible for:
* Saving all discovered introduction timestamps into a report
* Comparing two reports against each other to find episodes that:
* Are missing introductions in both reports
* Have introductions in both reports, but with different timestamps
* Newly discovered introductions
* Introductions that were discovered previously, but not anymore
* Validating the schema of returned `Intro` objects from the `/IntroTimestamps` API endpoint
### Usage examples
* Generate intro timestamp report from a local server:
* `./verifier -address http://127.0.0.1:8096 -key api_key`
* Generate intro timestamp report from a remote server, polling for task completion every 20 seconds:
* `./verifier -address https://example.com -key api_key -poll 20s -o example.json`
* Compare two previously generated reports:
* `./verifier -r1 v0.1.5.json -r2 v0.1.6.json`
* Validate the API schema for three episodes:
* `./verifier -address http://127.0.0.1:8096 -key api_key -validate id1,id2,id3`
## Selenium web interface tests
Selenium is used to verify that the plugin's web interface works as expected. It simulates a user:
* Clicking the skip intro button
* Checks that clicking the button skips the intro and keeps playing the video
* Changing settings (will be added in the future)
* Maximum degree of parallelism
* Selecting libraries for analysis
* EDL settings
* Introduction requirements
* Auto skip
* Show/hide skip prompt
* Timestamp editor (will be added in the future)
* Displays timestamps
* Modifies timestamps
* Erases season timestamps
* Fingerprint visualizer (will be added in the future)
* Suggests shifts
* Visualizer canvas is drawn on

View File

@ -1,10 +0,0 @@
#!/bin/bash
echo "[+] Building timestamp verifier"
(cd verifier && go build -o verifier) || exit 1
echo "[+] Building test wrapper"
(cd wrapper && go test ./... && go build -o ../run_tests) || exit 1
echo
echo "[+] All programs built successfully"

View File

@ -1,22 +0,0 @@
{
"common": {
"library": "/full/path/to/test/library/on/host/TV",
"episode": "Episode title to search for"
},
"servers": [
{
"comment": "Optional comment to identify this server",
"image": "ghcr.io/confusedpolarbear/jellyfin-intro-skipper:latest",
"username": "admin",
"password": "hunter2",
"browsers": [
"chrome",
"firefox"
], // supported values are "chrome" and "firefox".
"tests": [
"skip_button", // test skip intro button
"settings" // test plugin administration page
]
}
]
}

View File

@ -1,33 +0,0 @@
version: "3"
services:
chrome:
image: selenium/standalone-chrome:106.0
shm_size: 2gb
ports:
- 4444:4444
environment:
- SE_NODE_SESSION_TIMEOUT=10
firefox:
image: selenium/standalone-firefox:105.0
shm_size: 2gb
ports:
- 4445:4444
environment:
- SE_NODE_SESSION_TIMEOUT=10
chrome_video:
image: selenium/video
environment:
- DISPLAY_CONTAINER_NAME=chrome
- FILE_NAME=chrome_video.mp4
volumes:
- /tmp/selenium/videos:/videos
firefox_video:
image: selenium/video
environment:
- DISPLAY_CONTAINER_NAME=firefox
- FILE_NAME=firefox_video.mp4
volumes:
- /tmp/selenium/videos:/videos

View File

@ -1,203 +0,0 @@
import argparse, os, time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
# Driver function
def main():
# Parse CLI arguments and store in a dictionary
parser = argparse.ArgumentParser()
parser.add_argument("-host", help="Jellyfin server address with protocol and port.")
parser.add_argument("-username", help="Username.")
parser.add_argument("-password", help="Password.")
parser.add_argument("-name", help="Name of episode to search for.")
parser.add_argument(
"--tests", help="Space separated list of Selenium tests to run.", type=str, nargs="+"
)
parser.add_argument(
"--browsers",
help="Space separated list of browsers to run tests with.",
type=str,
nargs="+",
choices=["chrome", "firefox"],
)
args = parser.parse_args()
server = {
"host": args.host,
"username": args.username,
"password": args.password,
"episode": args.name,
"browsers": args.browsers,
"tests": args.tests,
}
# Print the server info for debugging and run the test
print()
print(f"Browsers: {server['browsers']}")
print(f"Address: {server['host']}")
print(f"Username: {server['username']}")
print(f"Episode: \"{server['episode']}\"")
print(f"Tests: {server['tests']}")
print()
# Setup the list of drivers to run tests with
if server["browsers"] is None:
print("[!] --browsers is required")
exit(1)
drivers = []
if "chrome" in server["browsers"]:
drivers = [("http://127.0.0.1:4444", "Chrome")]
if "firefox" in server["browsers"]:
drivers.append(("http://127.0.0.1:4445", "Firefox"))
# Test with all selected drivers
for driver in drivers:
print(f"[!] Starting new test run using {driver[1]}")
test_server(server, driver[0], driver[1])
print()
# Main server test function
def test_server(server, executor, driver_type):
# Configure Selenium to use a remote driver
print(f"[+] Configuring Selenium to use executor {executor} of type {driver_type}")
opts = None
if driver_type == "Chrome":
opts = webdriver.ChromeOptions()
elif driver_type == "Firefox":
opts = webdriver.FirefoxOptions()
else:
raise ValueError(f"Unknown driver type {driver_type}")
driver = webdriver.Remote(command_executor=executor, options=opts)
try:
# Wait up to two seconds when finding an element before reporting failure
driver.implicitly_wait(2)
# Login to Jellyfin
driver.get(make_url(server, "/"))
print(f"[+] Authenticating as {server['username']}")
login(driver, server)
if "skip_button" in server["tests"]:
# Play the user specified episode and verify skip intro button functionality. This episode is expected to:
# * already have been analyzed for an introduction
# * have an introduction at the beginning of the episode
print("[+] Testing skip intro button")
test_skip_button(driver, server)
print("[+] All tests completed successfully")
finally:
# Unconditionally end the Selenium session
driver.quit()
def login(driver, server):
# Append the Enter key to the password to submit the form
us = server["username"]
pw = server["password"] + Keys.ENTER
# Fill out and submit the login form
driver.find_element(By.ID, "txtManualName").send_keys(us)
driver.find_element(By.ID, "txtManualPassword").send_keys(pw)
def test_skip_button(driver, server):
print(f" [+] Searching for episode \"{server['episode']}\"")
search = driver.find_element(By.CSS_SELECTOR, ".headerSearchButton span.search")
if driver.capabilities["browserName"] == "firefox":
# Work around a FF bug where the search element isn't considered clickable right away
time.sleep(1)
# Click the search button
search.click()
# Type the episode name
driver.find_element(By.CSS_SELECTOR, ".searchfields-txtSearch").send_keys(
server["episode"]
)
# Click the first episode in the search results
driver.find_element(
By.CSS_SELECTOR, ".searchResults button[data-type='Episode']"
).click()
# Wait for the episode page to finish loading by searching for the episode description (overview)
driver.find_element(By.CSS_SELECTOR, ".overview")
print(f" [+] Waiting for playback to start")
# Click the play button in the toolbar
driver.find_element(
By.CSS_SELECTOR, "div.mainDetailButtons span.play_arrow"
).click()
# Wait for playback to start by searching for the lower OSD control bar
driver.find_element(By.CSS_SELECTOR, ".osdControls")
# Let the video play a little bit so the position before clicking the button can be logged
print(" [+] Playing video")
time.sleep(2)
screenshot(driver, "skip_button_pre_skip")
assert_video_playing(driver)
# Find the skip intro button and click it, logging the new video position after the seek is preformed
print(" [+] Clicking skip intro button")
driver.find_element(By.CSS_SELECTOR, "div#skipIntro").click()
time.sleep(1)
screenshot(driver, "skip_button_post_skip")
assert_video_playing(driver)
# Keep playing the video for a few seconds to ensure that:
# * the intro was successfully skipped
# * video playback continued automatically post button click
print(" [+] Verifying post skip position")
time.sleep(4)
screenshot(driver, "skip_button_post_play")
assert_video_playing(driver)
# Utility functions
def make_url(server, url):
final = server["host"] + url
print(f"[+] Navigating to {final}")
return final
def screenshot(driver, filename):
dest = f"screenshots/{filename}.png"
driver.save_screenshot(dest)
# Returns the current video playback position and if the video is paused.
# Will raise an exception if playback is paused as the video shouldn't ever pause when using this plugin.
def assert_video_playing(driver):
ret = driver.execute_script(
"""
const video = document.querySelector("video");
return {
"position": video.currentTime,
"paused": video.paused
};
"""
)
if ret["paused"]:
raise Exception("Video should not be paused")
print(f" [+] Video playback position: {ret['position']}")
return ret
main()

View File

@ -1,3 +0,0 @@
module github.com/confusedpolarbear/intro_skipper_verifier
go 1.17

View File

@ -1,78 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/confusedpolarbear/intro_skipper_verifier/structs"
)
// Gets the contents of the provided URL or panics.
func SendRequest(method, url, apiKey string) []byte {
http.DefaultClient.Timeout = 10 * time.Second
// Construct the request
req, err := http.NewRequest(method, url, nil)
if err != nil {
panic(err)
}
// Include the authorization token
req.Header.Set("Authorization", fmt.Sprintf(`MediaBrowser Token="%s"`, apiKey))
// Send the request
res, err := http.DefaultClient.Do(req)
if !strings.Contains(url, "hideUrl") {
fmt.Printf("[+] %s %s: %d\n", method, url, res.StatusCode)
}
// Panic if any error occurred
if err != nil {
panic(err)
}
// Check for API key validity
if res.StatusCode == http.StatusUnauthorized {
panic("Server returned 401 (Unauthorized). Check API key validity and try again.")
}
// Read and return the entire body
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
return body
}
func GetServerInfo(hostAddress, apiKey string) structs.PublicInfo {
var info structs.PublicInfo
fmt.Println("[+] Getting server information")
rawInfo := SendRequest("GET", hostAddress+"/System/Info/Public", apiKey)
if err := json.Unmarshal(rawInfo, &info); err != nil {
panic(err)
}
return info
}
func GetPluginConfiguration(hostAddress, apiKey string) structs.PluginConfiguration {
var config structs.PluginConfiguration
fmt.Println("[+] Getting plugin configuration")
rawConfig := SendRequest("GET", hostAddress+"/Plugins/c83d86bb-a1e0-4c35-a113-e2101cf4ee6b/Configuration", apiKey)
if err := json.Unmarshal(rawConfig, &config); err != nil {
panic(err)
}
return config
}

View File

@ -1,63 +0,0 @@
package main
import (
"flag"
"time"
)
func flags() {
// Report generation
hostAddress := flag.String("address", "", "Address of Jellyfin server to extract intro information from.")
apiKey := flag.String("key", "", "Administrator API key to authenticate with.")
keepTimestamps := flag.Bool("keep", false, "Keep the current timestamps instead of erasing and reanalyzing.")
pollInterval := flag.Duration("poll", 10*time.Second, "Interval to poll task completion at.")
reportDestination := flag.String("o", "", "Report destination filename. Defaults to intros-ADDRESS-TIMESTAMP.json.")
// Report comparison
report1 := flag.String("r1", "", "First report.")
report2 := flag.String("r2", "", "Second report.")
// API schema validator
ids := flag.String("validate", "", "Comma separated item ids to validate the API schema for.")
// Print usage examples
flag.CommandLine.Usage = func() {
flag.CommandLine.Output().Write([]byte("Flags:\n"))
flag.PrintDefaults()
usage := "\nUsage:\n" +
"Generate intro timestamp report from a local server:\n" +
"./verifier -address http://127.0.0.1:8096 -key api_key\n\n" +
"Generate intro timestamp report from a remote server, polling for task completion every 20 seconds:\n" +
"./verifier -address https://example.com -key api_key -poll 20s -o example.json\n\n" +
"Compare two previously generated reports:\n" +
"./verifier -r1 v0.1.5.json -r2 v0.1.6.json\n\n" +
"Validate the API schema for some item ids:\n" +
"./verifier -address http://127.0.0.1:8096 -key api_key -validate id1,id2,id3\n"
flag.CommandLine.Output().Write([]byte(usage))
}
flag.Parse()
if *hostAddress != "" && *apiKey != "" {
if *ids == "" {
generateReport(*hostAddress, *apiKey, *reportDestination, *keepTimestamps, *pollInterval)
} else {
validateApiSchema(*hostAddress, *apiKey, *ids)
}
} else if *report1 != "" && *report2 != "" {
compareReports(*report1, *report2, *reportDestination)
} else {
panic("Either (-address and -key) or (-r1 and -r2) are required.")
}
}
func main() {
flags()
}

View File

@ -1,389 +0,0 @@
<!DOCTYPE html>
<html>
<!-- TODO: when templating this, pre-populate the ignored shows value with something pulled from a config file -->
<head>
<style>
/* dark mode */
body {
background-color: #1e1e1e;
color: white;
}
/* enable borders on the table row */
table {
border-collapse: collapse;
}
table td {
padding-right: 5px;
}
/* remove top & bottom margins */
.report-info *,
.episode * {
margin-bottom: 0;
margin-top: 0;
}
/* visually separate the report header from the contents */
.report-info .report {
background-color: #0c3c55;
border-radius: 7px;
display: inline-block;
}
.report h3 {
display: inline;
}
.report.stats {
margin-top: 4px;
}
details {
margin-bottom: 10px;
}
summary {
cursor: pointer;
}
/* prevent the details from taking up the entire width of the screen */
.show>details {
max-width: 50%;
}
/* indent season headers some */
.season {
margin-left: 1em;
}
/* indent individual episode timestamps some more */
.episode {
margin-left: 1em;
}
/* if an intro was not found previously but is now, that's good */
.episode[data-warning="improvement"] {
background-color: #044b04;
}
/* if an intro was found previously but isn't now, that's bad */
.episode[data-warning="only_previous"],
.episode[data-warning="missing"] {
background-color: firebrick;
}
/* if an intro was found on both runs but the timestamps are pretty different, that's interesting */
.episode[data-warning="different"] {
background-color: #b77600;
}
#stats.warning {
border: 2px solid firebrick;
font-weight: bolder;
}
</style>
</head>
<body>
<div class="report-info">
<h2 class="margin-bottom:1em">Intro Timestamp Differential</h2>
<div class="report old">
<h3 style="margin-top:0.5em">First report</h3>
{{ block "ReportInfo" .OldReport }}
<table>
<tbody>
<tr style="border-bottom: 1px solid black">
<td>Path</td>
<td><code>{{ .Path }}</code></td>
</tr>
<tr>
<td>Jellyfin</td>
<td>{{ .ServerInfo.Version }} on {{ .ServerInfo.OperatingSystem }}</td>
</tr>
<tr>
<td>Analysis Settings</td>
<td>{{ printAnalysisSettings .PluginConfig }}</td>
</tr>
<tr style="border-bottom: 1px solid black">
<td>Introduction Requirements</td>
<td>{{ printIntroductionReqs .PluginConfig }}</td>
</tr>
<tr>
<td>Start time</td>
<td>{{ printTime .StartedAt }}</td>
</tr>
<tr>
<td>End time</td>
<td>{{ printTime .FinishedAt }}</td>
</tr>
<tr>
<td>Duration</td>
<td>{{ printDuration .Runtime }}</td>
</tr>
</tbody>
</table>
{{ end }}
</div>
<div class="report new">
<h3 style="padding-top:0.5em">Second report</h3>
{{ template "ReportInfo" .NewReport }}
</div>
<br />
<div class="report stats">
<h3 style="padding-top:0.5em">Statistics</h3>
<table>
<tbody>
<tr>
<td>Total episodes</td>
<td id="statTotal"></td>
</tr>
<tr>
<td>Never found</td>
<td id="statMissing"></td>
</tr>
<tr>
<td>Changed</td>
<td id="statChanged"></td>
</tr>
<tr>
<td>Gains</td>
<td id="statGain"></td>
</tr>
<tr>
<td>Losses</td>
<td id="statLoss"></td>
</tr>
</tbody>
</table>
</div>
<div class="report settings">
<h3 style="padding-top:0.5em">Settings</h3>
<form style="display:table">
<label for="minimumPercentage">Minimum percentage</label>
<input id="minimumPercentage" type="number" value="85" min="0" max="100"
style="margin-left: 5px; max-width: 100px" /> <br />
<label for="ignoreShows">Ignored shows</label>
<input id="ignoredShows" type="text" /> <br />
<button id="btnUpdate" type="button">Update</button>
</form>
</div>
</div>
{{/* store a reference to the data before the range query */}}
{{ $p := . }}
{{/* sort the show names and iterate over them */}}
{{ range $name := sortShows .OldReport.Shows }}
<div class="show" id="{{ $name }}">
<details>
{{/* get the unsorted seasons for this show */}}
{{ $seasons := index $p.OldReport.Shows $name }}
{{/* log the show name and number of seasons */}}
<summary>
<span class="showTitle">
<strong>{{ $name }}</strong>
<span id="stats"></span>
</span>
</summary>
<div class="seasons">
{{/* sort the seasons to ensure they display in numerical order */}}
{{ range $seasonNumber := (sortSeasons $seasons) }}
<div class="season" id="{{ $name }}-{{ $seasonNumber }}">
<details>
<summary>
<span>
<strong>Season {{ $seasonNumber }}</strong>
<span id="stats"></span>
</span>
</summary>
{{/* compare each episode in the old report to the same episode in the new report */}}
{{ range $episode := index $seasons $seasonNumber }}
{{/* lookup and compare both episodes */}}
{{ $comparison := compareEpisodes $episode.EpisodeId $p }}
{{ $old := $comparison.Old }}
{{ $new := $comparison.New }}
{{/* set attributes indicating if an intro was found in the old and new reports */}}
<div class="episode" data-warning="{{ $comparison.WarningShort }}">
<p>{{ $episode.Title }}</p>
<p>
Old: {{ $old.FormattedStart }} - {{ $old.FormattedEnd }}
(<span class="duration old">{{ $old.Duration }}</span>)
(valid: {{ $old.Valid }}) <br />
New: {{ $new.FormattedStart }} - {{ $new.FormattedEnd }}
(<span class="duration new">{{ $new.Duration }}</span>)
(valid: {{ $new.Valid }}) <br />
{{ if ne $comparison.WarningShort "okay" }}
Warning: {{ $comparison.Warning }}
{{ end }}
</p>
<br />
</div>
{{ end }}
</details>
</div>
{{ end }}
</div>
</details>
</div>
{{ end }}
<script>
function count(parent, warning) {
const sel = `div.episode[data-warning='${warning}']`
// Don't include hidden elements in the count
let count = 0;
for (const elem of parent.querySelectorAll(sel)) {
// offsetParent is defined when the element is not hidden
if (elem.offsetParent) {
count++;
}
}
return count;
}
function getPercent(part, whole) {
const percent = Math.round((part * 10_000) / whole) / 100;
return `${part} (${percent}%)`;
}
function setText(selector, text) {
document.querySelector(selector).textContent = text;
}
// Gets the minimum percentage of episodes in a group (a series or season)
// that must have a detected introduction.
function getMinimumPercentage() {
const value = document.querySelector("#minimumPercentage").value;
return Number(value);
}
// Gets the average duration for all episodes in a parent group.
// durationClass must be either "old" or "new".
function getAverageDuration(parent, durationClass) {
// Get all durations in the parent
const elems = parent.querySelectorAll(".duration." + durationClass);
// Calculate the average duration, ignoring any episode without an intro
let totalDuration = 0;
let totalEpisodes = 0;
for (const e of elems) {
const dur = Number(e.textContent);
if (dur === 0) {
continue;
}
totalDuration += dur;
totalEpisodes++;
}
if (totalEpisodes === 0) {
return 0;
}
return Math.round(totalDuration / totalEpisodes);
}
// Calculate statistics for all episodes in a parent element (a series or a season).
function setGroupStatistics(parent) {
// Count the total number of episodes.
const total = parent.querySelectorAll("div.episode").length;
// Count how many episodes have no warnings.
const okayCount = count(parent, "okay") + count(parent, "improvement");
const okayPercent = Math.round((okayCount * 100) / total);
const isOkay = okayPercent >= getMinimumPercentage();
// Calculate the previous and current average durations
const oldDuration = getAverageDuration(parent, "old");
const newDuration = getAverageDuration(parent, "new");
// Display the statistics
const stats = parent.querySelector("#stats");
stats.textContent = `${okayCount} / ${total} (${okayPercent}%) okay. r1 ${oldDuration} r2 ${newDuration}`;
if (!isOkay) {
stats.classList.add("warning");
} else {
stats.classList.remove("warning");
}
}
function updateGlobalStatistics() {
// Display all shows
for (const show of document.querySelectorAll("div.show")) {
show.style.display = "unset";
}
// Hide any shows that are ignored
for (let ignored of document.querySelector("#ignoredShows").value.split(",")) {
const elem = document.querySelector(`div.show[id='${ignored}']`);
if (!elem) {
console.warn("unable to find show", ignored);
continue;
}
elem.style.display = "none";
}
const total = document.querySelectorAll("div.episode").length;
const missing = count(document, "missing");
const different = count(document, "different")
const gain = count(document, "improvement");
const loss = count(document, "only_previous");
const okay = total - missing - different - loss;
setText("#statTotal", getPercent(okay, total));
setText("#statMissing", getPercent(missing, total));
setText("#statChanged", getPercent(different, total));
setText("#statGain", getPercent(gain, total));
setText("#statLoss", getPercent(loss, total));
}
function updateStatistics() {
for (const series of document.querySelectorAll("div.show")) {
setGroupStatistics(series);
for (const season of series.querySelectorAll("div.season")) {
setGroupStatistics(season);
}
}
}
// Display statistics for all episodes and by groups
updateGlobalStatistics();
updateStatistics();
// Add event handlers
document.querySelector("#minimumPercentage").addEventListener("input", updateStatistics);
document.querySelector("#btnUpdate").addEventListener("click", updateGlobalStatistics);
</script>
</body>
</html>

View File

@ -1,139 +0,0 @@
package main
import (
_ "embed"
"encoding/json"
"fmt"
"html/template"
"math"
"os"
"time"
"github.com/confusedpolarbear/intro_skipper_verifier/structs"
)
//go:embed report.html
var reportTemplate []byte
func compareReports(oldReportPath, newReportPath, destination string) {
start := time.Now()
// Populate the destination filename if none was provided
if destination == "" {
destination = fmt.Sprintf("report-%d.html", start.Unix())
}
// Open the report for writing
f, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
panic(err)
} else {
defer f.Close()
}
fmt.Printf("Started at: %s\n", start.Format(time.RFC1123))
fmt.Printf("First report: %s\n", oldReportPath)
fmt.Printf("Second report: %s\n", newReportPath)
fmt.Printf("Destination: %s\n\n", destination)
// Unmarshal both reports
oldReport, newReport := unmarshalReport(oldReportPath), unmarshalReport(newReportPath)
fmt.Println("[+] Comparing reports")
// Setup a function map with helper functions to use in the template
tmp := template.New("report")
funcs := make(template.FuncMap)
funcs["printTime"] = func(t time.Time) string {
return t.Format(time.RFC1123)
}
funcs["printDuration"] = func(d time.Duration) string {
return d.Round(time.Second).String()
}
funcs["printAnalysisSettings"] = func(pc structs.PluginConfiguration) string {
return pc.AnalysisSettings()
}
funcs["printIntroductionReqs"] = func(pc structs.PluginConfiguration) string {
return pc.IntroductionRequirements()
}
funcs["sortShows"] = templateSortShows
funcs["sortSeasons"] = templateSortSeason
funcs["compareEpisodes"] = templateCompareEpisodes
tmp.Funcs(funcs)
// Load the template or panic
report := template.Must(tmp.Parse(string(reportTemplate)))
err = report.Execute(f,
structs.TemplateReportData{
OldReport: oldReport,
NewReport: newReport,
})
if err != nil {
panic(err)
}
// Log success
fmt.Printf("[+] Reports successfully compared in %s\n", time.Since(start).Round(time.Millisecond))
}
func unmarshalReport(path string) structs.Report {
// Read the provided report
contents, err := os.ReadFile(path)
if err != nil {
panic(err)
}
// Unmarshal
var report structs.Report
if err := json.Unmarshal(contents, &report); err != nil {
panic(err)
}
// Setup maps and template data for later use
report.Path = path
report.Shows = make(map[string]structs.Seasons)
report.IntroMap = make(map[string]structs.Intro)
// Sort episodes by show and season
for _, intro := range report.Intros {
// Round the duration to the nearest second to avoid showing 8 decimal places in the report
intro.Duration = float32(math.Round(float64(intro.Duration)))
// Pretty print the intro start and end times
intro.FormattedStart = (time.Duration(intro.IntroStart) * time.Second).String()
intro.FormattedEnd = (time.Duration(intro.IntroEnd) * time.Second).String()
show, season := intro.Series, intro.Season
// If this show hasn't been seen before, allocate space for it
if _, ok := report.Shows[show]; !ok {
report.Shows[show] = make(structs.Seasons)
}
// Store this intro in the season of this show
episodes := report.Shows[show][season]
episodes = append(episodes, intro)
report.Shows[show][season] = episodes
// Store a reference to this intro in a lookup table
report.IntroMap[intro.EpisodeId] = intro
}
// Print report info
fmt.Printf("Report %s:\n", path)
fmt.Printf("Generated with Jellyfin %s running on %s\n", report.ServerInfo.Version, report.ServerInfo.OperatingSystem)
fmt.Printf("Analysis settings: %s\n", report.PluginConfig.AnalysisSettings())
fmt.Printf("Introduction reqs: %s\n", report.PluginConfig.IntroductionRequirements())
fmt.Printf("Episodes analyzed: %d\n", len(report.Intros))
fmt.Println()
return report
}

View File

@ -1,81 +0,0 @@
package main
import (
"fmt"
"math"
"sort"
"github.com/confusedpolarbear/intro_skipper_verifier/structs"
)
// report template helper functions
// Sort show names alphabetically
func templateSortShows(shows map[string]structs.Seasons) []string {
var showNames []string
for show := range shows {
showNames = append(showNames, show)
}
sort.Strings(showNames)
return showNames
}
// Sort season numbers
func templateSortSeason(show structs.Seasons) []int {
var keys []int
for season := range show {
keys = append(keys, season)
}
sort.Ints(keys)
return keys
}
// Compare the episode with the provided ID in the old report to the episode in the new report.
func templateCompareEpisodes(id string, reports structs.TemplateReportData) structs.IntroPair {
var pair structs.IntroPair
var tolerance int = 5
// Locate both episodes
pair.Old = reports.OldReport.IntroMap[id]
pair.New = reports.NewReport.IntroMap[id]
// Mark the timestamps as similar if they are within a few seconds of each other
similar := func(oldTime, newTime float32) bool {
diff := math.Abs(float64(newTime) - float64(oldTime))
return diff <= float64(tolerance)
}
if pair.Old.Valid && !pair.New.Valid {
// If an intro was found previously, but not now, flag it
pair.WarningShort = "only_previous"
pair.Warning = "Introduction found in previous report, but not the current one"
} else if !pair.Old.Valid && pair.New.Valid {
// If an intro was not found previously, but found now, flag it
pair.WarningShort = "improvement"
pair.Warning = "New introduction discovered"
} else if !pair.Old.Valid && !pair.New.Valid {
// If an intro has never been found for this episode
pair.WarningShort = "missing"
pair.Warning = "No introduction has ever been found for this episode"
} else if !similar(pair.Old.IntroStart, pair.New.IntroStart) || !similar(pair.Old.IntroEnd, pair.New.IntroEnd) {
// If the intro timestamps are too different, flag it
pair.WarningShort = "different"
pair.Warning = fmt.Sprintf("Timestamps differ by more than %d seconds", tolerance)
} else {
// No warning was generated
pair.WarningShort = "okay"
pair.Warning = "Okay"
}
return pair
}

View File

@ -1,180 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/confusedpolarbear/intro_skipper_verifier/structs"
)
var spinners []string
var spinnerIndex int
func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamps bool, pollInterval time.Duration) {
start := time.Now()
// Setup the spinner
spinners = strings.Split("⣷⣯⣟⡿⢿⣻⣽⣾", "")
spinnerIndex = -1 // start the spinner on the first graphic
// Setup the filename to save intros to
if reportDestination == "" {
reportDestination = fmt.Sprintf("intros-%s-%d.json", hostAddress, time.Now().Unix())
reportDestination = strings.ReplaceAll(reportDestination, "http://", "")
reportDestination = strings.ReplaceAll(reportDestination, "https://", "")
}
// Ensure the file is writable
if err := os.WriteFile(reportDestination, nil, 0600); err != nil {
panic(err)
}
fmt.Printf("Started at: %s\n", start.Format(time.RFC1123))
fmt.Printf("Address: %s\n", hostAddress)
fmt.Printf("Destination: %s\n", reportDestination)
fmt.Println()
// Get Jellyfin server information and plugin configuration
info := GetServerInfo(hostAddress, apiKey)
config := GetPluginConfiguration(hostAddress, apiKey)
fmt.Println()
fmt.Printf("Jellyfin OS: %s\n", info.OperatingSystem)
fmt.Printf("Jellyfin version: %s\n", info.Version)
fmt.Printf("Analysis settings: %s\n", config.AnalysisSettings())
fmt.Printf("Introduction reqs: %s\n", config.IntroductionRequirements())
fmt.Printf("Erase timestamps: %t\n", !keepTimestamps)
fmt.Println()
// If not keeping timestamps, run the fingerprint task.
// Otherwise, log that the task isn't being run
if !keepTimestamps {
runAnalysisAndWait(hostAddress, apiKey, pollInterval)
} else {
fmt.Println("[+] Using previously discovered intros")
}
fmt.Println()
// Save all intros from the server
fmt.Println("[+] Saving intros")
var report structs.Report
rawIntros := SendRequest("GET", hostAddress+"/Intros/All", apiKey)
if err := json.Unmarshal(rawIntros, &report.Intros); err != nil {
panic(err)
}
// Calculate the durations of all intros
for i := range report.Intros {
intro := report.Intros[i]
intro.Duration = intro.IntroEnd - intro.IntroStart
report.Intros[i] = intro
}
fmt.Println()
fmt.Println("[+] Saving report")
// Store timing data, server information, and plugin configuration
report.StartedAt = start
report.FinishedAt = time.Now()
report.Runtime = report.FinishedAt.Sub(report.StartedAt)
report.ServerInfo = info
report.PluginConfig = config
// Marshal the report
marshalled, err := json.Marshal(report)
if err != nil {
panic(err)
}
if err := os.WriteFile(reportDestination, marshalled, 0600); err != nil {
panic(err)
}
// Change report permissions
exec.Command("chown", "1000:1000", reportDestination).Run()
fmt.Println("[+] Done")
}
func runAnalysisAndWait(hostAddress, apiKey string, pollInterval time.Duration) {
var taskId string = ""
type taskInfo struct {
State string
CurrentProgressPercentage int
}
fmt.Println("[+] Erasing previously discovered intros")
SendRequest("POST", hostAddress+"/Intros/EraseTimestamps", apiKey)
fmt.Println()
var taskIds = []string{
"f64d8ad58e3d7b98548e1a07697eb100", // v0.1.8
"8863329048cc357f7dfebf080f2fe204",
"6adda26c5261c40e8fa4a7e7df568be2"}
fmt.Println("[+] Starting analysis task")
for _, id := range taskIds {
body := SendRequest("POST", hostAddress+"/ScheduledTasks/Running/"+id, apiKey)
fmt.Println()
// If the scheduled task was found, store the task ID for later
if !strings.Contains(string(body), "Not Found") {
taskId = id
break
}
}
if taskId == "" {
panic("unable to find scheduled task")
}
fmt.Println("[+] Waiting for analysis task to complete")
fmt.Print("[+] Episodes analyzed: 0%")
var info taskInfo // Last known scheduled task state
var lastQuery time.Time // Time the task info was last updated
for {
time.Sleep(500 * time.Millisecond)
// Update the spinner
if spinnerIndex++; spinnerIndex >= len(spinners) {
spinnerIndex = 0
}
fmt.Printf("\r[%s] Episodes analyzed: %d%%", spinners[spinnerIndex], info.CurrentProgressPercentage)
if info.CurrentProgressPercentage == 100 {
fmt.Printf("\r[+]") // reset the spinner
fmt.Println()
break
}
// Get the latest task state & unmarshal (only if enough time has passed since the last update)
if time.Since(lastQuery) <= pollInterval {
continue
}
lastQuery = time.Now()
raw := SendRequest("GET", hostAddress+"/ScheduledTasks/"+taskId+"?hideUrl=1", apiKey)
if err := json.Unmarshal(raw, &info); err != nil {
fmt.Printf("[!] Unable to unmarshal response into taskInfo struct: %s\n", err)
fmt.Printf("%s\n", raw)
continue
}
// Print the latest task state
switch info.State {
case "Idle":
info.CurrentProgressPercentage = 100
}
}
}

View File

@ -1,116 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/confusedpolarbear/intro_skipper_verifier/structs"
)
// Given a comma separated list of item IDs, validate the returned API schema.
func validateApiSchema(hostAddress, apiKey, rawIds string) {
// Iterate over the raw item IDs and validate the schema of API responses
ids := strings.Split(rawIds, ",")
start := time.Now()
fmt.Printf("Started at: %s\n", start.Format(time.RFC1123))
fmt.Printf("Address: %s\n", hostAddress)
fmt.Println()
// Get Jellyfin server information
info := GetServerInfo(hostAddress, apiKey)
fmt.Println()
fmt.Printf("Jellyfin OS: %s\n", info.OperatingSystem)
fmt.Printf("Jellyfin version: %s\n", info.Version)
fmt.Println()
for _, id := range ids {
fmt.Printf("[+] Validating item %s\n", id)
fmt.Println(" [+] Validating API v1 (implicitly versioned)")
intro, schema := getTimestampsV1(hostAddress, apiKey, id, "")
validateV1Intro(id, intro, schema)
fmt.Println(" [+] Validating API v1 (explicitly versioned)")
intro, schema = getTimestampsV1(hostAddress, apiKey, id, "v1")
validateV1Intro(id, intro, schema)
fmt.Println()
}
fmt.Printf("Validated %d items in %s\n", len(ids), time.Since(start).Round(time.Millisecond))
}
// Validates the returned intro object, panicking on any error.
func validateV1Intro(id string, intro structs.Intro, schema map[string]interface{}) {
// Validate the item ID
if intro.EpisodeId != id {
panic(fmt.Sprintf("Intro struct has incorrect item ID. Expected '%s', found '%s'", id, intro.EpisodeId))
}
// Validate the intro start and end times
if intro.IntroStart < 0 || intro.IntroEnd < 0 {
panic("Intro struct has a negative intro start or end time")
}
if intro.ShowSkipPromptAt > intro.IntroStart {
panic("Intro struct show prompt time is after intro start")
}
if intro.HideSkipPromptAt > intro.IntroEnd {
panic("Intro struct hide prompt time is after intro end")
}
// Validate the intro duration
if duration := intro.IntroEnd - intro.IntroStart; duration < 15 {
panic(fmt.Sprintf("Intro struct has duration %0.2f but the minimum allowed is 15", duration))
}
// Ensure the intro is marked as valid.
if !intro.Valid {
panic("Intro struct is not marked as valid")
}
// Check for any extraneous properties
allowedProperties := []string{"EpisodeId", "Valid", "IntroStart", "IntroEnd", "ShowSkipPromptAt", "HideSkipPromptAt"}
for schemaKey := range schema {
okay := false
for _, allowed := range allowedProperties {
if allowed == schemaKey {
okay = true
break
}
}
if !okay {
panic(fmt.Sprintf("Intro object contains unknown key '%s'", schemaKey))
}
}
}
// Gets the timestamps for the provided item or panics.
func getTimestampsV1(hostAddress, apiKey, id, version string) (structs.Intro, map[string]interface{}) {
var rawResponse map[string]interface{}
var intro structs.Intro
// Make an authenticated GET request to {Host}/Episode/{ItemId}/IntroTimestamps/{Version}
raw := SendRequest("GET", fmt.Sprintf("%s/Episode/%s/IntroTimestamps/%s?hideUrl=1", hostAddress, id, version), apiKey)
// Unmarshal the response as a version 1 API response, ignoring any unknown fields.
if err := json.Unmarshal(raw, &intro); err != nil {
panic(err)
}
// Second, unmarshal the response into a map so that any unknown fields can be detected and alerted on.
if err := json.Unmarshal(raw, &rawResponse); err != nil {
panic(err)
}
return intro, rawResponse
}

View File

@ -1,20 +0,0 @@
package structs
type Intro struct {
EpisodeId string
Series string
Season int
Title string
IntroStart float32
IntroEnd float32
Duration float32
Valid bool
FormattedStart string
FormattedEnd string
ShowSkipPromptAt float32
HideSkipPromptAt float32
}

View File

@ -1,44 +0,0 @@
package structs
import (
"fmt"
"strings"
)
type PluginConfiguration struct {
CacheFingerprints bool
MaxParallelism int
SelectedLibraries string
AnalysisPercent int
AnalysisLengthLimit int
MinimumIntroDuration int
}
func (c PluginConfiguration) AnalysisSettings() string {
// If no libraries have been selected, display a star.
// Otherwise, quote each library before displaying the slice.
var libs []string
if c.SelectedLibraries == "" {
libs = []string{"*"}
} else {
for _, tmp := range strings.Split(c.SelectedLibraries, ",") {
tmp = `"` + strings.TrimSpace(tmp) + `"`
libs = append(libs, tmp)
}
}
return fmt.Sprintf(
"cfp=%t thr=%d lbs=%v",
c.CacheFingerprints,
c.MaxParallelism,
libs)
}
func (c PluginConfiguration) IntroductionRequirements() string {
return fmt.Sprintf(
"per=%d%% max=%dm min=%ds",
c.AnalysisPercent,
c.AnalysisLengthLimit,
c.MinimumIntroDuration)
}

View File

@ -1,6 +0,0 @@
package structs
type PublicInfo struct {
Version string
OperatingSystem string
}

View File

@ -1,48 +0,0 @@
package structs
import "time"
type Seasons map[int][]Intro
type Report struct {
Path string `json:"-"`
StartedAt time.Time
FinishedAt time.Time
Runtime time.Duration
ServerInfo PublicInfo
PluginConfig PluginConfiguration
Intros []Intro
// Intro lookup table. Only populated when loading a report.
IntroMap map[string]Intro `json:"-"`
// Intros which have been sorted by show and season number. Only populated when loading a report.
Shows map[string]Seasons `json:"-"`
}
// Data passed to the report template.
type TemplateReportData struct {
// First report.
OldReport Report
// Second report.
NewReport Report
}
// A pair of introductions from an old and new reports.
type IntroPair struct {
Old Intro
New Intro
// Recognized warning types:
// * okay: no warning
// * different: timestamps are too dissimilar
// * only_previous: introduction found in old report but not new one
WarningShort string
// If this pair of intros is not okay, a short description about the cause
Warning string
}

View File

@ -1,64 +0,0 @@
package main
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"regexp"
"strings"
"time"
)
// Run an external program
func RunProgram(program string, args []string, timeout time.Duration) {
// Flag if we are starting or stopping a container
managingContainer := program == "docker"
// Create context and command
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, program, args...)
// Stringify and censor the program's arguments
strArgs := redactString(strings.Join(args, " "))
fmt.Printf(" [+] Running %s %s\n", program, strArgs)
// Setup pipes
stdout, err := cmd.StdoutPipe()
if err != nil {
panic(err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
panic(err)
}
// Start the command
if err := cmd.Start(); err != nil {
panic(err)
}
// Stream any messages to the terminal
for _, r := range []io.Reader{stdout, stderr} {
// Don't log stdout from the container
if managingContainer && r == stdout {
continue
}
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanRunes)
for scanner.Scan() {
fmt.Print(scanner.Text())
}
}
}
// Redacts sensitive command line arguments.
func redactString(raw string) string {
redactionRegex := regexp.MustCompilePOSIX(`-(user|pass|key) [^ ]+`)
return redactionRegex.ReplaceAllString(raw, "-$1 REDACTED")
}

View File

@ -1,13 +0,0 @@
package main
import "testing"
func TestStringRedaction(t *testing.T) {
raw := "-key deadbeef -first second -user admin -third fourth -pass hunter2"
expected := "-key REDACTED -first second -user REDACTED -third fourth -pass REDACTED"
actual := redactString(raw)
if expected != actual {
t.Errorf(`String was redacted incorrectly: "%s"`, actual)
}
}

View File

@ -1,3 +0,0 @@
module github.com/confusedpolarbear/intro_skipper_wrapper
go 1.17

View File

@ -1,93 +0,0 @@
{
"LibraryOptions": {
"EnableArchiveMediaFiles": false,
"EnablePhotos": false,
"EnableRealtimeMonitor": false,
"ExtractChapterImagesDuringLibraryScan": false,
"EnableChapterImageExtraction": false,
"EnableInternetProviders": false,
"SaveLocalMetadata": false,
"EnableAutomaticSeriesGrouping": false,
"PreferredMetadataLanguage": "",
"MetadataCountryCode": "",
"SeasonZeroDisplayName": "Specials",
"AutomaticRefreshIntervalDays": 0,
"EnableEmbeddedTitles": false,
"EnableEmbeddedEpisodeInfos": false,
"AllowEmbeddedSubtitles": "AllowAll",
"SkipSubtitlesIfEmbeddedSubtitlesPresent": false,
"SkipSubtitlesIfAudioTrackMatches": false,
"SaveSubtitlesWithMedia": true,
"RequirePerfectSubtitleMatch": true,
"AutomaticallyAddToCollection": false,
"MetadataSavers": [],
"TypeOptions": [
{
"Type": "Series",
"MetadataFetchers": [
"TheMovieDb",
"The Open Movie Database"
],
"MetadataFetcherOrder": [
"TheMovieDb",
"The Open Movie Database"
],
"ImageFetchers": [
"TheMovieDb"
],
"ImageFetcherOrder": [
"TheMovieDb"
]
},
{
"Type": "Season",
"MetadataFetchers": [
"TheMovieDb"
],
"MetadataFetcherOrder": [
"TheMovieDb"
],
"ImageFetchers": [
"TheMovieDb"
],
"ImageFetcherOrder": [
"TheMovieDb"
]
},
{
"Type": "Episode",
"MetadataFetchers": [
"TheMovieDb",
"The Open Movie Database"
],
"MetadataFetcherOrder": [
"TheMovieDb",
"The Open Movie Database"
],
"ImageFetchers": [
"TheMovieDb",
"The Open Movie Database",
"Embedded Image Extractor",
"Screen Grabber"
],
"ImageFetcherOrder": [
"TheMovieDb",
"The Open Movie Database",
"Embedded Image Extractor",
"Screen Grabber"
]
}
],
"LocalMetadataReaderOrder": [
"Nfo"
],
"SubtitleDownloadLanguages": [],
"DisabledSubtitleFetchers": [],
"SubtitleFetcherOrder": [],
"PathInfos": [
{
"Path": "/media/TV"
}
]
}
}

View File

@ -1,380 +0,0 @@
package main
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"time"
)
// IP address to use when connecting to local containers.
var containerAddress string
// Path to compiled plugin DLL to install in local containers.
var pluginPath string
// Randomly generated password used to setup container with.
var containerPassword string
func flags() {
flag.StringVar(&pluginPath, "dll", "", "Path to plugin DLL to install in container images.")
flag.StringVar(&containerAddress, "caddr", "", "IP address to use when connecting to local containers.")
flag.Parse()
// Randomize the container's password
rawPassword := make([]byte, 32)
if _, err := rand.Read(rawPassword); err != nil {
panic(err)
}
containerPassword = hex.EncodeToString(rawPassword)
}
func main() {
flags()
start := time.Now()
fmt.Printf("[+] Start time: %s\n", start)
// Load list of servers
fmt.Println("[+] Loading configuration")
config := loadConfiguration()
fmt.Println()
// Start Selenium by bringing up the compose file in detatched mode
fmt.Println("[+] Starting Selenium")
RunProgram("docker-compose", []string{"up", "-d"}, 10*time.Second)
// If any error occurs, bring Selenium down before exiting
defer func() {
fmt.Println("[+] Stopping Selenium")
RunProgram("docker-compose", []string{"down"}, 15*time.Second)
}()
// Test all provided Jellyfin servers
for _, server := range config.Servers {
if server.Skip {
continue
}
var configurationDirectory string
var apiKey string
var seleniumArgs []string
// LSIO containers use some slighly different paths & permissions
lsioImage := strings.Contains(server.Image, "linuxserver")
fmt.Println()
fmt.Printf("[+] Testing %s\n", server.Comment)
if server.Docker {
var err error
// Setup a temporary folder for the container's configuration
configurationDirectory, err = os.MkdirTemp("/dev/shm", "jf-e2e-*")
if err != nil {
panic(err)
}
// Create a folder to install the plugin into
pluginDirectory := path.Join(configurationDirectory, "plugins", "intro-skipper")
if lsioImage {
pluginDirectory = path.Join(configurationDirectory, "data", "plugins", "intro-skipper")
}
fmt.Println(" [+] Creating plugin directory")
if err := os.MkdirAll(pluginDirectory, 0700); err != nil {
fmt.Printf(" [!] Failed to create plugin directory: %s\n", err)
goto cleanup
}
// If this is an LSIO container, adjust the permissions on the plugin directory
if lsioImage {
RunProgram(
"chown",
[]string{
"911:911",
"-R",
path.Join(configurationDirectory, "data", "plugins")},
2*time.Second)
}
// Install the plugin
fmt.Printf(" [+] Copying plugin %s to %s\n", pluginPath, pluginDirectory)
RunProgram("cp", []string{pluginPath, pluginDirectory}, 2*time.Second)
fmt.Println()
/* Start the container with the following settings:
* Name: jf-e2e
* Port: 8097
* Media: Mounted to /media, read only
*/
containerArgs := []string{"run", "--name", "jf-e2e", "--rm", "-p", "8097:8096",
"-v", fmt.Sprintf("%s:%s:rw", configurationDirectory, "/config"),
"-v", fmt.Sprintf("%s:%s:ro", config.Common.Library, "/media"),
server.Image}
fmt.Printf(" [+] Starting container %s\n", server.Image)
go RunProgram("docker", containerArgs, 60*time.Second)
// Wait for the container to fully start
waitForServerStartup(server.Address)
fmt.Println()
fmt.Println(" [+] Setting up container")
// Set up the container
SetupServer(server.Address, containerPassword)
// Restart the container and wait for it to come back up
RunProgram("docker", []string{"restart", "jf-e2e"}, 10*time.Second)
time.Sleep(time.Second)
waitForServerStartup(server.Address)
fmt.Println()
} else {
fmt.Println("[+] Remote instance, assuming plugin is already installed")
}
// Get an API key
apiKey = login(server)
// Rescan the library if this is a server that we just setup
if server.Docker {
fmt.Println(" [+] Rescanning library")
sendRequest(
server.Address+"/ScheduledTasks/Running/7738148ffcd07979c7ceb148e06b3aed?api_key="+apiKey,
"POST",
"")
// TODO: poll for task completion
time.Sleep(10 * time.Second)
fmt.Println()
}
// Analyze episodes and save report
fmt.Println(" [+] Analyzing episodes")
fmt.Print("\033[37;1m") // change the color of the verifier's text
RunProgram(
"./verifier/verifier",
[]string{
"-address", server.Address,
"-key", apiKey, "-o",
fmt.Sprintf("reports/%s-%d.json", server.Comment, start.Unix())},
5*time.Minute)
fmt.Print("\033[39;0m") // reset terminal text color
// Pause for any manual tests
if server.ManualTests {
fmt.Println(" [!] Pausing for manual tests")
reader := bufio.NewReader(os.Stdin)
reader.ReadString('\n')
}
// Setup base Selenium arguments
seleniumArgs = []string{
"-u", // force stdout to be unbuffered
"main.py",
"-host", server.Address,
"-user", server.Username,
"-pass", server.Password,
"-name", config.Common.Episode}
// Append all requested Selenium tests
seleniumArgs = append(seleniumArgs, "--tests")
seleniumArgs = append(seleniumArgs, server.Tests...)
// Append all requested browsers
seleniumArgs = append(seleniumArgs, "--browsers")
seleniumArgs = append(seleniumArgs, server.Browsers...)
// Run Selenium
os.Chdir("selenium")
RunProgram("python3", seleniumArgs, time.Minute)
os.Chdir("..")
cleanup:
if server.Docker {
// Stop the container
fmt.Println(" [+] Stopping and removing container")
RunProgram("docker", []string{"stop", "jf-e2e"}, 10*time.Second)
// Cleanup the container's configuration
fmt.Printf(" [+] Deleting %s\n", configurationDirectory)
if err := os.RemoveAll(configurationDirectory); err != nil {
panic(err)
}
}
}
}
// Login to the specified Jellyfin server and return an API key
func login(server Server) string {
type AuthenticateUserByName struct {
AccessToken string
}
fmt.Println(" [+] Sending authentication request")
// Create request body
rawBody := fmt.Sprintf(`{"Username":"%s","Pw":"%s"}`, server.Username, server.Password)
body := bytes.NewBufferString(rawBody)
// Create the request
req, err := http.NewRequest(
"POST",
fmt.Sprintf("%s/Users/AuthenticateByName", server.Address),
body)
if err != nil {
panic(err)
}
// Set headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set(
"X-Emby-Authorization",
`MediaBrowser Client="JF E2E Tests", Version="0.0.1", DeviceId="E2E", Device="E2E"`)
// Authenticate
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
} else if res.StatusCode != http.StatusOK {
panic(fmt.Sprintf("authentication returned code %d", res.StatusCode))
}
defer res.Body.Close()
// Read body
fullBody, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
// Unmarshal body and return token
var token AuthenticateUserByName
if err := json.Unmarshal(fullBody, &token); err != nil {
panic(err)
}
return token.AccessToken
}
// Wait up to ten seconds for the provided Jellyfin server to fully startup
func waitForServerStartup(address string) {
attempts := 10
fmt.Println(" [+] Waiting for server to finish starting")
for {
// Sleep in between requests
time.Sleep(time.Second)
// Ping the /System/Info/Public endpoint
res, err := http.Get(fmt.Sprintf("%s/System/Info/Public", address))
// If the server didn't return 200 OK, loop
if err != nil || res.StatusCode != http.StatusOK {
if attempts--; attempts <= 0 {
panic("server is taking too long to startup")
}
continue
}
// Assume startup has finished, break
break
}
}
// Read configuration from config.json
func loadConfiguration() Configuration {
var config Configuration
// Load the contents of the configuration file
raw, err := os.ReadFile("config.json")
if err != nil {
panic(err)
}
// Unmarshal
if err := json.Unmarshal(raw, &config); err != nil {
panic(err)
}
// Print debugging info
fmt.Printf("Library: %s\n", config.Common.Library)
fmt.Printf("Episode: \"%s\"\n", config.Common.Episode)
fmt.Printf("Password: %s\n", containerPassword)
fmt.Println()
// Check the validity of all entries
for i, server := range config.Servers {
// If this is an entry for a local container, ensure the server address is correct
if server.Image != "" {
// Ensure that values were provided for the host's IP address, base configuration directory,
// and a path to the compiled plugin DLL to install.
if containerAddress == "" {
panic("The -caddr argument is required.")
}
if pluginPath == "" {
panic("The -dll argument is required.")
}
server.Username = "admin"
server.Password = containerPassword
server.Address = fmt.Sprintf("http://%s:8097", containerAddress)
server.Docker = true
}
// If no browsers were specified, default to Chrome (for speed)
if len(server.Browsers) == 0 {
server.Browsers = []string{"chrome"}
}
// If no tests were specified, only test that the plugin settings page works
if len(server.Tests) == 0 {
server.Tests = []string{"settings"}
}
// Verify that an address was provided
if len(server.Address) == 0 {
panic("Server address is required")
}
fmt.Printf("===== Server: %s =====\n", server.Comment)
if server.Skip {
fmt.Println("Skip: true")
}
fmt.Printf("Docker: %t\n", server.Docker)
if server.Docker {
fmt.Printf("Image: %s\n", server.Image)
}
fmt.Printf("Address: %s\n", server.Address)
fmt.Printf("Browsers: %v\n", server.Browsers)
fmt.Printf("Tests: %v\n", server.Tests)
fmt.Println()
config.Servers[i] = server
}
fmt.Println("=================")
return config
}

View File

@ -1,79 +0,0 @@
package main
import (
"bytes"
_ "embed"
"fmt"
"net/http"
)
//go:embed library.json
var librarySetupPayload string
func SetupServer(server, password string) {
makeUrl := func(u string) string {
return fmt.Sprintf("%s/%s", server, u)
}
// Set the server language to English
sendRequest(
makeUrl("Startup/Configuration"),
"POST",
`{"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}`)
// Get the first user
sendRequest(makeUrl("Startup/User"), "GET", "")
// Create the first user
sendRequest(
makeUrl("Startup/User"),
"POST",
fmt.Sprintf(`{"Name":"admin","Password":"%s"}`, password))
// Create a TV library from the media at /media/TV.
sendRequest(
makeUrl("Library/VirtualFolders?collectionType=tvshows&refreshLibrary=false&name=Shows"),
"POST",
librarySetupPayload)
// Setup remote access
sendRequest(
makeUrl("Startup/RemoteAccess"),
"POST",
`{"EnableRemoteAccess":true,"EnableAutomaticPortMapping":false}`)
// Mark the wizard as complete
sendRequest(
makeUrl("Startup/Complete"),
"POST",
``)
}
func sendRequest(url string, method string, body string) {
// Create the request
req, err := http.NewRequest(method, url, bytes.NewBuffer([]byte(body)))
if err != nil {
panic(err)
}
// Set required headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set(
"X-Emby-Authorization",
`MediaBrowser Client="JF E2E Tests", Version="0.0.1", DeviceId="E2E", Device="E2E"`)
// Send it
fmt.Printf(" [+] %s %s", method, url)
res, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println()
panic(err)
}
fmt.Printf(" %d\n", res.StatusCode)
if res.StatusCode != http.StatusNoContent && res.StatusCode != http.StatusOK {
panic("invalid status code received during setup")
}
}

View File

@ -1,26 +0,0 @@
package main
type Configuration struct {
Common Common `json:"common"`
Servers []Server `json:"servers"`
}
type Common struct {
Library string `json:"library"`
Episode string `json:"episode"`
}
type Server struct {
Skip bool `json:"skip"`
Comment string `json:"comment"`
Address string `json:"address"`
Image string `json:"image"`
Username string `json:"username"`
Password string `json:"password"`
Browsers []string `json:"browsers"`
Tests []string `json:"tests"`
ManualTests bool `json:"manual_tests"`
// These properties are set at runtime
Docker bool `json:"-"`
}

View File

@ -1,22 +0,0 @@
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}"
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}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.Build.0 = Release|Any CPU
{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -1,204 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Media file analyzer used to detect end credits that consist of text overlaid on a black background.
/// Bisects the end of the video file to perform an efficient search.
/// </summary>
public class BlackFrameAnalyzer : IMediaFileAnalyzer
{
private readonly TimeSpan _maximumError = new(0, 0, 4);
private readonly ILogger<BlackFrameAnalyzer> _logger;
private int minimumCreditsDuration;
private int maximumCreditsDuration;
private int blackFrameMinimumPercentage;
/// <summary>
/// Initializes a new instance of the <see cref="BlackFrameAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger)
{
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
minimumCreditsDuration = config.MinimumCreditsDuration;
maximumCreditsDuration = 2 * config.MaximumCreditsDuration;
blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage;
_logger = logger;
}
/// <inheritdoc />
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
if (mode != AnalysisMode.Credits)
{
throw new NotImplementedException("mode must equal Credits");
}
var creditTimes = new Dictionary<Guid, Intro>();
bool isFirstEpisode = true;
double searchStart = minimumCreditsDuration;
var searchDistance = 2 * minimumCreditsDuration;
foreach (var episode in analysisQueue)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
// Pre-check to find reasonable starting point.
if (isFirstEpisode)
{
var scanTime = episode.Duration - searchStart;
var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here.
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, blackFrameMinimumPercentage);
while (frames.Length > 0) // While black frames are found increase searchStart
{
searchStart += searchDistance;
scanTime = episode.Duration - searchStart;
tr = new TimeRange(scanTime - 0.5, scanTime);
frames = FFmpegWrapper.DetectBlackFrames(episode, tr, blackFrameMinimumPercentage);
if (searchStart > maximumCreditsDuration)
{
searchStart = maximumCreditsDuration;
break;
}
}
if (searchStart == minimumCreditsDuration) // Skip if no black frames were found
{
continue;
}
isFirstEpisode = false;
}
var intro = AnalyzeMediaFile(
episode,
searchStart,
searchDistance,
blackFrameMinimumPercentage);
if (intro is null)
{
// If no credits were found, reset the first-episode search logic for the next episode in the sequence.
searchStart = minimumCreditsDuration;
isFirstEpisode = true;
continue;
}
searchStart = episode.Duration - intro.IntroStart + (0.5 * searchDistance);
creditTimes[episode.EpisodeId] = intro;
}
Plugin.Instance!.UpdateTimestamps(creditTimes, mode);
return analysisQueue
.Where(x => !creditTimes.ContainsKey(x.EpisodeId))
.ToList()
.AsReadOnly();
}
/// <summary>
/// Analyzes an individual media file. Only public because of unit tests.
/// </summary>
/// <param name="episode">Media file to analyze.</param>
/// <param name="searchStart">Search Start Piont.</param>
/// <param name="searchDistance">Search Distance.</param>
/// <param name="minimum">Percentage of the frame that must be black.</param>
/// <returns>Credits timestamp.</returns>
public Intro? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int searchDistance, int minimum)
{
// Start by analyzing the last N minutes of the file.
var upperLimit = searchStart;
var lowerLimit = Math.Max(searchStart - searchDistance, minimumCreditsDuration);
var start = TimeSpan.FromSeconds(upperLimit);
var end = TimeSpan.FromSeconds(lowerLimit);
var firstFrameTime = 0.0;
// Continue bisecting the end of the file until the range that contains the first black
// frame is smaller than the maximum permitted error.
while (start - end > _maximumError)
{
// Analyze the middle two seconds from the current bisected range
var midpoint = (start + end) / 2;
var scanTime = episode.Duration - midpoint.TotalSeconds;
var tr = new TimeRange(scanTime, scanTime + 2);
_logger.LogTrace(
"{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]",
episode.Name,
episode.Duration,
start,
end,
tr.Start,
tr.End);
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum);
_logger.LogTrace(
"{Episode} at {Start} has {Count} black frames",
episode.Name,
tr.Start,
frames.Length);
if (frames.Length == 0)
{
// Since no black frames were found, slide the range closer to the end
start = midpoint - TimeSpan.FromSeconds(2);
if (midpoint - TimeSpan.FromSeconds(lowerLimit) < _maximumError)
{
lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), minimumCreditsDuration);
// Reset end for a new search with the increased duration
end = TimeSpan.FromSeconds(lowerLimit);
}
}
else
{
// Some black frames were found, slide the range closer to the start
end = midpoint;
firstFrameTime = frames[0].Time + scanTime;
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError)
{
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), maximumCreditsDuration);
// Reset start for a new search with the increased duration
start = TimeSpan.FromSeconds(upperLimit);
}
}
}
if (firstFrameTime > 0)
{
return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration));
}
return null;
}
}

View File

@ -1,245 +0,0 @@
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 MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Chapter name analyzer.
/// </summary>
public class ChapterAnalyzer : IMediaFileAnalyzer
{
private ILogger<ChapterAnalyzer> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ChapterAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public ChapterAnalyzer(ILogger<ChapterAnalyzer> logger)
{
_logger = logger;
}
/// <inheritdoc />
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
var skippableRanges = new Dictionary<Guid, Intro>();
var expression = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern :
Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern;
if (string.IsNullOrWhiteSpace(expression))
{
return analysisQueue;
}
foreach (var episode in analysisQueue)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var skipRange = FindMatchingChapter(
episode,
new(Plugin.Instance.GetChapters(episode.EpisodeId)),
expression,
mode);
if (skipRange is null)
{
continue;
}
skippableRanges.Add(episode.EpisodeId, skipRange);
}
Plugin.Instance.UpdateTimestamps(skippableRanges, mode);
return analysisQueue
.Where(x => !skippableRanges.ContainsKey(x.EpisodeId))
.ToList()
.AsReadOnly();
}
/// <summary>
/// Searches a list of chapter names for one that matches the provided regular expression.
/// Only public to allow for unit testing.
/// </summary>
/// <param name="episode">Episode.</param>
/// <param name="chapters">Media item chapters.</param>
/// <param name="expression">Regular expression pattern.</param>
/// <param name="mode">Analysis mode.</param>
/// <returns>Intro object containing skippable time range, or null if no chapter matched.</returns>
public Intro? FindMatchingChapter(
QueuedEpisode episode,
Collection<ChapterInfo> chapters,
string expression,
AnalysisMode mode)
{
Intro? matchingChapter = null;
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var minDuration = config.MinimumIntroDuration;
int maxDuration = mode == AnalysisMode.Introduction ?
config.MaximumIntroDuration :
config.MaximumCreditsDuration;
if (chapters.Count == 0)
{
return null;
}
if (mode == AnalysisMode.Credits)
{
// Since the ending credits chapter may be the last chapter in the file, append a virtual
// chapter at the very end of the file.
chapters.Add(new()
{
StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks
});
// Check all chapters in reverse order, skipping the virtual chapter
for (int i = chapters.Count - 2; i > 0; i--)
{
var current = chapters[i];
var previous = chapters[i - 1];
if (string.IsNullOrWhiteSpace(current.Name))
{
continue;
}
var currentRange = new TimeRange(
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
TimeSpan.FromTicks(chapters[i + 1].StartPositionTicks).TotalSeconds);
var baseMessage = string.Format(
CultureInfo.InvariantCulture,
"{0}: Chapter \"{1}\" ({2} - {3})",
episode.Path,
current.Name,
currentRange.Start,
currentRange.End);
if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration)
{
_logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage);
continue;
}
// Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex
// between function invocations.
var match = Regex.IsMatch(
current.Name,
expression,
RegexOptions.None,
TimeSpan.FromSeconds(1));
if (!match)
{
_logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
continue;
}
if (!string.IsNullOrWhiteSpace(previous.Name))
{
// Check for possibility of overlapping keywords
var overlap = Regex.IsMatch(
previous.Name,
expression,
RegexOptions.None,
TimeSpan.FromSeconds(1));
if (overlap)
{
continue;
}
}
matchingChapter = new(episode.EpisodeId, currentRange);
_logger.LogTrace("{Base}: okay", baseMessage);
break;
}
}
else
{
// Check all chapters
for (int i = 0; i < chapters.Count - 1; i++)
{
var current = chapters[i];
var next = chapters[i + 1];
if (string.IsNullOrWhiteSpace(current.Name))
{
continue;
}
var currentRange = new TimeRange(
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
var baseMessage = string.Format(
CultureInfo.InvariantCulture,
"{0}: Chapter \"{1}\" ({2} - {3})",
episode.Path,
current.Name,
currentRange.Start,
currentRange.End);
if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration)
{
_logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage);
continue;
}
// Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex
// between function invocations.
var match = Regex.IsMatch(
current.Name,
expression,
RegexOptions.None,
TimeSpan.FromSeconds(1));
if (!match)
{
_logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
continue;
}
if (!string.IsNullOrWhiteSpace(next.Name))
{
// Check for possibility of overlapping keywords
var overlap = Regex.IsMatch(
next.Name,
expression,
RegexOptions.None,
TimeSpan.FromSeconds(1));
if (overlap)
{
continue;
}
}
matchingChapter = new(episode.EpisodeId, currentRange);
_logger.LogTrace("{Base}: okay", baseMessage);
break;
}
}
return matchingChapter;
}
}

View File

@ -1,486 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Numerics;
using System.Threading;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Chromaprint audio analyzer.
/// </summary>
public class ChromaprintAnalyzer : IMediaFileAnalyzer
{
/// <summary>
/// Seconds of audio in one fingerprint point.
/// This value is defined by the Chromaprint library and should not be changed.
/// </summary>
private const double SamplesToSeconds = 0.1238;
private int minimumIntroDuration;
private int maximumDifferences;
private int invertedIndexShift;
private double maximumTimeSkip;
private double silenceDetectionMinimumDuration;
private ILogger<ChromaprintAnalyzer> _logger;
private AnalysisMode _analysisMode;
/// <summary>
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger)
{
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
maximumDifferences = config.MaximumFingerprintPointDifferences;
invertedIndexShift = config.InvertedIndexShift;
maximumTimeSkip = config.MaximumTimeSkip;
silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration;
minimumIntroDuration = config.MinimumIntroDuration;
_logger = logger;
}
/// <inheritdoc />
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
// All intros for this season.
var seasonIntros = new Dictionary<Guid, Intro>();
// Cache of all fingerprints for this season.
var fingerprintCache = new Dictionary<Guid, uint[]>();
// Episode analysis queue.
var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue);
// Episodes that were analyzed and do not have an introduction.
var episodesWithoutIntros = new List<QueuedEpisode>();
this._analysisMode = mode;
// Compute fingerprints for all episodes in the season
foreach (var episode in episodeAnalysisQueue)
{
try
{
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode);
// Use reversed fingerprints for credits
if (_analysisMode == AnalysisMode.Credits)
{
Array.Reverse(fingerprintCache[episode.EpisodeId]);
}
if (cancellationToken.IsCancellationRequested)
{
return analysisQueue;
}
}
catch (FingerprintException ex)
{
_logger.LogDebug("Caught fingerprint error: {Ex}", ex);
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
// Fallback to an empty fingerprint on any error
fingerprintCache[episode.EpisodeId] = Array.Empty<uint>();
}
}
// While there are still episodes in the queue
while (episodeAnalysisQueue.Count > 0)
{
// Pop the first episode from the queue
var currentEpisode = episodeAnalysisQueue[0];
episodeAnalysisQueue.RemoveAt(0);
// Search through all remaining episodes.
foreach (var remainingEpisode in episodeAnalysisQueue)
{
// Compare the current episode to all remaining episodes in the queue.
var (currentIntro, remainingIntro) = CompareEpisodes(
currentEpisode.EpisodeId,
fingerprintCache[currentEpisode.EpisodeId],
remainingEpisode.EpisodeId,
fingerprintCache[remainingEpisode.EpisodeId]);
// Ignore this comparison result if:
// - one of the intros isn't valid, or
// - the introduction exceeds the configured limit
if (
!remainingIntro.Valid ||
remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration)
{
continue;
}
/* Since the Fingerprint() function returns an array of Chromaprint points without time
* information, the times reported from the index search function start from 0.
*
* While this is desired behavior for detecting introductions, it breaks credit
* detection, as the audio we're analyzing was extracted from some point into the file.
*
* To fix this, the starting and ending times need to be switched, as they were previously reversed
* and subtracted from the episode duration to get the reported time range.
*/
if (this._analysisMode == AnalysisMode.Credits)
{
// Calculate new values for the current intro
double currentOriginalIntroStart = currentIntro.IntroStart;
currentIntro.IntroStart = currentEpisode.Duration - currentIntro.IntroEnd;
currentIntro.IntroEnd = currentEpisode.Duration - currentOriginalIntroStart;
// Calculate new values for the remaining intro
double remainingIntroOriginalStart = remainingIntro.IntroStart;
remainingIntro.IntroStart = remainingEpisode.Duration - remainingIntro.IntroEnd;
remainingIntro.IntroEnd = remainingEpisode.Duration - remainingIntroOriginalStart;
}
// Only save the discovered intro if it is:
// - the first intro discovered for this episode
// - longer than the previously discovered intro
if (
!seasonIntros.TryGetValue(currentIntro.EpisodeId, out var savedCurrentIntro) ||
currentIntro.Duration > savedCurrentIntro.Duration)
{
seasonIntros[currentIntro.EpisodeId] = currentIntro;
}
if (
!seasonIntros.TryGetValue(remainingIntro.EpisodeId, out var savedRemainingIntro) ||
remainingIntro.Duration > savedRemainingIntro.Duration)
{
seasonIntros[remainingIntro.EpisodeId] = remainingIntro;
}
break;
}
// If no intro is found at this point, the popped episode is not reinserted into the queue.
if (!seasonIntros.ContainsKey(currentEpisode.EpisodeId))
{
episodesWithoutIntros.Add(currentEpisode);
}
}
// If cancellation was requested, report that no episodes were analyzed.
if (cancellationToken.IsCancellationRequested)
{
return analysisQueue;
}
if (this._analysisMode == AnalysisMode.Introduction)
{
// Adjust all introduction end times so that they end at silence.
seasonIntros = AdjustIntroEndTimes(analysisQueue, seasonIntros);
}
Plugin.Instance!.UpdateTimestamps(seasonIntros, this._analysisMode);
return episodesWithoutIntros.AsReadOnly();
}
/// <summary>
/// Analyze two episodes to find an introduction sequence shared between them.
/// </summary>
/// <param name="lhsId">First episode id.</param>
/// <param name="lhsPoints">First episode fingerprint points.</param>
/// <param name="rhsId">Second episode id.</param>
/// <param name="rhsPoints">Second episode fingerprint points.</param>
/// <returns>Intros for the first and second episodes.</returns>
public (Intro Lhs, Intro Rhs) CompareEpisodes(
Guid lhsId,
uint[] lhsPoints,
Guid rhsId,
uint[] rhsPoints)
{
// Creates an inverted fingerprint point index for both episodes.
// For every point which is a 100% match, search for an introduction at that point.
var (lhsRanges, rhsRanges) = SearchInvertedIndex(lhsId, lhsPoints, rhsId, rhsPoints);
if (lhsRanges.Count > 0)
{
_logger.LogTrace("Index search successful");
return GetLongestTimeRange(lhsId, lhsRanges, rhsId, rhsRanges);
}
_logger.LogTrace(
"Unable to find a shared introduction sequence between {LHS} and {RHS}",
lhsId,
rhsId);
return (new Intro(lhsId), new Intro(rhsId));
}
/// <summary>
/// Locates the longest range of similar audio and returns an Intro class for each range.
/// </summary>
/// <param name="lhsId">First episode id.</param>
/// <param name="lhsRanges">First episode shared timecodes.</param>
/// <param name="rhsId">Second episode id.</param>
/// <param name="rhsRanges">Second episode shared timecodes.</param>
/// <returns>Intros for the first and second episodes.</returns>
private (Intro Lhs, Intro Rhs) GetLongestTimeRange(
Guid lhsId,
List<TimeRange> lhsRanges,
Guid rhsId,
List<TimeRange> rhsRanges)
{
// Store the longest time range as the introduction.
lhsRanges.Sort();
rhsRanges.Sort();
var lhsIntro = lhsRanges[0];
var rhsIntro = rhsRanges[0];
// If the intro starts early in the episode, move it to the beginning.
if (lhsIntro.Start <= 5)
{
lhsIntro.Start = 0;
}
if (rhsIntro.Start <= 5)
{
rhsIntro.Start = 0;
}
// Create Intro classes for each time range.
return (new Intro(lhsId, lhsIntro), new Intro(rhsId, rhsIntro));
}
/// <summary>
/// Search for a shared introduction sequence using inverted indexes.
/// </summary>
/// <param name="lhsId">LHS ID.</param>
/// <param name="lhsPoints">Left episode fingerprint points.</param>
/// <param name="rhsId">RHS ID.</param>
/// <param name="rhsPoints">Right episode fingerprint points.</param>
/// <returns>List of shared TimeRanges between the left and right episodes.</returns>
private (List<TimeRange> Lhs, List<TimeRange> Rhs) SearchInvertedIndex(
Guid lhsId,
uint[] lhsPoints,
Guid rhsId,
uint[] rhsPoints)
{
var lhsRanges = new List<TimeRange>();
var rhsRanges = new List<TimeRange>();
// Generate inverted indexes for the left and right episodes.
var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, this._analysisMode);
var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, this._analysisMode);
var indexShifts = new HashSet<int>();
// For all audio points in the left episode, check if the right episode has a point which matches exactly.
// If an exact match is found, calculate the shift that must be used to align the points.
foreach (var kvp in lhsIndex)
{
var originalPoint = kvp.Key;
for (var i = -1 * invertedIndexShift; i <= invertedIndexShift; i++)
{
var modifiedPoint = (uint)(originalPoint + i);
if (rhsIndex.TryGetValue(modifiedPoint, out var rhsModifiedPoint))
{
var lhsFirst = lhsIndex[originalPoint];
var rhsFirst = rhsModifiedPoint;
indexShifts.Add(rhsFirst - lhsFirst);
}
}
}
// Use all discovered shifts to compare the episodes.
foreach (var shift in indexShifts)
{
var (lhsIndexContiguous, rhsIndexContiguous) = FindContiguous(lhsPoints, rhsPoints, shift);
if (lhsIndexContiguous.End > 0 && rhsIndexContiguous.End > 0)
{
lhsRanges.Add(lhsIndexContiguous);
rhsRanges.Add(rhsIndexContiguous);
}
}
return (lhsRanges, rhsRanges);
}
/// <summary>
/// Finds the longest contiguous region of similar audio between two fingerprints using the provided shift amount.
/// </summary>
/// <param name="lhs">First fingerprint to compare.</param>
/// <param name="rhs">Second fingerprint to compare.</param>
/// <param name="shiftAmount">Amount to shift one fingerprint by.</param>
private (TimeRange Lhs, TimeRange Rhs) FindContiguous(
uint[] lhs,
uint[] rhs,
int shiftAmount)
{
var leftOffset = 0;
var rightOffset = 0;
// Calculate the offsets for the left and right hand sides.
if (shiftAmount < 0)
{
leftOffset -= shiftAmount;
}
else
{
rightOffset += shiftAmount;
}
// Store similar times for both LHS and RHS.
var lhsTimes = new List<double>();
var rhsTimes = new List<double>();
var upperLimit = Math.Min(lhs.Length, rhs.Length) - Math.Abs(shiftAmount);
// XOR all elements in LHS and RHS, using the shift amount from above.
for (var i = 0; i < upperLimit; i++)
{
// XOR both samples at the current position.
var lhsPosition = i + leftOffset;
var rhsPosition = i + rightOffset;
var diff = lhs[lhsPosition] ^ rhs[rhsPosition];
// If the difference between the samples is small, flag both times as similar.
if (CountBits(diff) > maximumDifferences)
{
continue;
}
var lhsTime = lhsPosition * SamplesToSeconds;
var rhsTime = rhsPosition * SamplesToSeconds;
lhsTimes.Add(lhsTime);
rhsTimes.Add(rhsTime);
}
// Ensure the last timestamp is checked
lhsTimes.Add(double.MaxValue);
rhsTimes.Add(double.MaxValue);
// Now that both fingerprints have been compared at this shift, see if there's a contiguous time range.
var lContiguous = TimeRangeHelpers.FindContiguous(lhsTimes.ToArray(), maximumTimeSkip);
if (lContiguous is null || lContiguous.Duration < minimumIntroDuration)
{
return (new TimeRange(), new TimeRange());
}
// Since LHS had a contiguous time range, RHS must have one also.
var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), maximumTimeSkip)!;
if (this._analysisMode == AnalysisMode.Introduction)
{
// Tweak the end timestamps just a bit to ensure as little content as possible is skipped over.
// TODO: remove this
if (lContiguous.Duration >= 90)
{
lContiguous.End -= 2 * maximumTimeSkip;
rContiguous.End -= 2 * maximumTimeSkip;
}
else if (lContiguous.Duration >= 30)
{
lContiguous.End -= maximumTimeSkip;
rContiguous.End -= maximumTimeSkip;
}
}
return (lContiguous, rContiguous);
}
/// <summary>
/// Adjusts the end timestamps of all intros so that they end at silence.
/// </summary>
/// <param name="episodes">QueuedEpisodes to adjust.</param>
/// <param name="originalIntros">Original introductions.</param>
private Dictionary<Guid, Intro> AdjustIntroEndTimes(
ReadOnlyCollection<QueuedEpisode> episodes,
Dictionary<Guid, Intro> originalIntros)
{
Dictionary<Guid, Intro> modifiedIntros = new();
// For all episodes
foreach (var episode in episodes)
{
_logger.LogTrace(
"Adjusting introduction end time for {Name} ({Id})",
episode.Name,
episode.EpisodeId);
// If no intro was found for this episode, skip it.
if (!originalIntros.TryGetValue(episode.EpisodeId, out var originalIntro))
{
_logger.LogTrace("{Name} does not have an intro", episode.Name);
continue;
}
// Only adjust the end timestamp of the intro
var originalIntroEnd = new TimeRange(originalIntro.IntroEnd - 15, originalIntro.IntroEnd);
_logger.LogTrace(
"{Name} original intro: {Start} - {End}",
episode.Name,
originalIntro.IntroStart,
originalIntro.IntroEnd);
// Detect silence in the media file up to the end of the intro.
var silence = FFmpegWrapper.DetectSilence(episode, (int)originalIntro.IntroEnd + 2);
// For all periods of silence
foreach (var currentRange in silence)
{
_logger.LogTrace(
"{Name} silence: {Start} - {End}",
episode.Name,
currentRange.Start,
currentRange.End);
// Ignore any silence that:
// * doesn't intersect the ending of the intro, or
// * is shorter than the user defined minimum duration, or
// * starts before the introduction does
if (
!originalIntroEnd.Intersects(currentRange) ||
currentRange.Duration < silenceDetectionMinimumDuration ||
currentRange.Start < originalIntro.IntroStart)
{
continue;
}
// Adjust the end timestamp of the intro to match the start of the silence region.
originalIntro.IntroEnd = currentRange.Start;
break;
}
_logger.LogTrace(
"{Name} adjusted intro: {Start} - {End}",
episode.Name,
originalIntro.IntroStart,
originalIntro.IntroEnd);
// Add the (potentially) modified intro back.
modifiedIntros[episode.EpisodeId] = originalIntro;
}
return modifiedIntros;
}
/// <summary>
/// Count the number of bits that are set in the provided number.
/// </summary>
/// <param name="number">Number to count bits in.</param>
/// <returns>Number of bits that are equal to 1.</returns>
public int CountBits(uint number)
{
return BitOperations.PopCount(number);
}
}

View File

@ -1,22 +0,0 @@
using System.Collections.ObjectModel;
using System.Threading;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Media file analyzer interface.
/// </summary>
public interface IMediaFileAnalyzer
{
/// <summary>
/// Analyze media files for shared introductions or credits, returning all media files that were **not successfully analyzed**.
/// </summary>
/// <param name="analysisQueue">Collection of unanalyzed media files.</param>
/// <param name="mode">Analysis mode.</param>
/// <param name="cancellationToken">Cancellation token from scheduled task.</param>
/// <returns>Collection of media files that were **unsuccessfully analyzed**.</returns>
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken);
}

View File

@ -1,31 +0,0 @@
using System.Collections.ObjectModel;
using System.Threading;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Chapter name analyzer.
/// </summary>
public class SegmentAnalyzer : IMediaFileAnalyzer
{
private ILogger<SegmentAnalyzer> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SegmentAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public SegmentAnalyzer(ILogger<SegmentAnalyzer> logger)
{
_logger = logger;
}
/// <inheritdoc />
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
return analysisQueue;
}
}

View File

@ -1,236 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Automatically skip past introduction sequences.
/// Commands clients to seek to the end of the intro as soon as they start playing it.
/// </summary>
public class AutoSkip : IHostedService, IDisposable
{
private readonly object _sentSeekCommandLock = new();
private ILogger<AutoSkip> _logger;
private IUserDataManager _userDataManager;
private ISessionManager _sessionManager;
private Timer _playbackTimer = new(1000);
private Dictionary<string, bool> _sentSeekCommand;
/// <summary>
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
/// </summary>
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public AutoSkip(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkip> logger)
{
_userDataManager = userDataManager;
_sessionManager = sessionManager;
_logger = logger;
_sentSeekCommand = new Dictionary<string, bool>();
}
private void AutoSkipChanged(object? sender, BasePluginConfiguration e)
{
var configuration = (PluginConfiguration)e;
var newState = configuration.AutoSkip;
_logger.LogDebug("Setting playback timer enabled to {NewState}", newState);
_playbackTimer.Enabled = newState;
}
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
{
var itemId = e.Item.Id;
var newState = false;
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
// Ignore all events except playback start & end
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
{
return;
}
// Lookup the session for this item.
SessionInfo? session = null;
try
{
foreach (var needle in _sessionManager.Sessions)
{
if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
{
session = needle;
break;
}
}
if (session == null)
{
_logger.LogInformation("Unable to find session for {Item}", itemId);
return;
}
}
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
{
return;
}
// If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session.
if (!Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
{
newState = true;
}
// Reset the seek command state for this device.
lock (_sentSeekCommandLock)
{
var device = session.DeviceId;
_logger.LogDebug("Resetting seek command state for session {Session}", device);
_sentSeekCommand[device] = newState;
}
}
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
foreach (var session in _sessionManager.Sessions)
{
var deviceId = session.DeviceId;
var itemId = session.NowPlayingItem.Id;
var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
// Don't send the seek command more than once in the same session.
lock (_sentSeekCommandLock)
{
if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent)
{
_logger.LogTrace("Already sent seek command for session {Session}", deviceId);
continue;
}
}
// Assert that an intro was detected for this item.
if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid)
{
continue;
}
// Seek is unreliable if called at the very start of an episode.
var adjustedStart = Math.Max(5, intro.IntroStart);
_logger.LogTrace(
"Playback position is {Position}, intro runs from {Start} to {End}",
position,
adjustedStart,
intro.IntroEnd);
if (position < adjustedStart || position > intro.IntroEnd)
{
continue;
}
// Notify the user that an introduction is being skipped for them.
var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText;
if (!string.IsNullOrWhiteSpace(notificationText))
{
_sessionManager.SendMessageCommand(
session.Id,
session.Id,
new MessageCommand
{
Header = string.Empty, // some clients require header to be a string instead of null
Text = notificationText,
TimeoutMs = 2000,
},
CancellationToken.None);
}
_logger.LogDebug("Sending seek command to {Session}", deviceId);
var introEnd = (long)intro.IntroEnd - Plugin.Instance.Configuration.SecondsOfIntroToPlay;
_sessionManager.SendPlaystateCommand(
session.Id,
session.Id,
new PlaystateRequest
{
Command = PlaystateCommand.Seek,
ControllingUserId = session.UserId.ToString(),
SeekPositionTicks = introEnd * TimeSpan.TicksPerSecond,
},
CancellationToken.None);
// Flag that we've sent the seek command so that it's not sent repeatedly
lock (_sentSeekCommandLock)
{
_logger.LogTrace("Setting seek command state for session {Session}", deviceId);
_sentSeekCommand[deviceId] = true;
}
}
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="disposing">Dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
_playbackTimer.Stop();
_playbackTimer.Dispose();
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Setting up automatic skipping");
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
Plugin.Instance!.ConfigurationChanged += AutoSkipChanged;
// Make the timer restart automatically and set enabled to match the configuration value.
_playbackTimer.AutoReset = true;
_playbackTimer.Elapsed += PlaybackTimer_Elapsed;
AutoSkipChanged(null, Plugin.Instance.Configuration);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
return Task.CompletedTask;
}
}

View File

@ -1,236 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Automatically skip past credit sequences.
/// Commands clients to seek to the end of the credits as soon as they start playing it.
/// </summary>
public class AutoSkipCredits : IHostedService, IDisposable
{
private readonly object _sentSeekCommandLock = new();
private ILogger<AutoSkipCredits> _logger;
private IUserDataManager _userDataManager;
private ISessionManager _sessionManager;
private Timer _playbackTimer = new(1000);
private Dictionary<string, bool> _sentSeekCommand;
/// <summary>
/// Initializes a new instance of the <see cref="AutoSkipCredits"/> class.
/// </summary>
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public AutoSkipCredits(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkipCredits> logger)
{
_userDataManager = userDataManager;
_sessionManager = sessionManager;
_logger = logger;
_sentSeekCommand = new Dictionary<string, bool>();
}
private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e)
{
var configuration = (PluginConfiguration)e;
var newState = configuration.AutoSkipCredits;
_logger.LogDebug("Setting playback timer enabled to {NewState}", newState);
_playbackTimer.Enabled = newState;
}
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
{
var itemId = e.Item.Id;
var newState = false;
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
// Ignore all events except playback start & end
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
{
return;
}
// Lookup the session for this item.
SessionInfo? session = null;
try
{
foreach (var needle in _sessionManager.Sessions)
{
if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
{
session = needle;
break;
}
}
if (session == null)
{
_logger.LogInformation("Unable to find session for {Item}", itemId);
return;
}
}
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
{
return;
}
// If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session.
if (!Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
{
newState = true;
}
// Reset the seek command state for this device.
lock (_sentSeekCommandLock)
{
var device = session.DeviceId;
_logger.LogDebug("Resetting seek command state for session {Session}", device);
_sentSeekCommand[device] = newState;
}
}
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
foreach (var session in _sessionManager.Sessions)
{
var deviceId = session.DeviceId;
var itemId = session.NowPlayingItem.Id;
var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
// Don't send the seek command more than once in the same session.
lock (_sentSeekCommandLock)
{
if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent)
{
_logger.LogTrace("Already sent seek command for session {Session}", deviceId);
continue;
}
}
// Assert that credits were detected for this item.
if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var credit) || !credit.Valid)
{
continue;
}
// Seek is unreliable if called at the very start of an episode.
var adjustedStart = Math.Max(5, credit.IntroStart);
_logger.LogTrace(
"Playback position is {Position}, credits run from {Start} to {End}",
position,
adjustedStart,
credit.IntroEnd);
if (position < adjustedStart || position > credit.IntroEnd)
{
continue;
}
// Notify the user that credits are being skipped for them.
var notificationText = Plugin.Instance!.Configuration.AutoSkipCreditsNotificationText;
if (!string.IsNullOrWhiteSpace(notificationText))
{
_sessionManager.SendMessageCommand(
session.Id,
session.Id,
new MessageCommand
{
Header = string.Empty, // some clients require header to be a string instead of null
Text = notificationText,
TimeoutMs = 2000,
},
CancellationToken.None);
}
_logger.LogDebug("Sending seek command to {Session}", deviceId);
var creditEnd = (long)credit.IntroEnd;
_sessionManager.SendPlaystateCommand(
session.Id,
session.Id,
new PlaystateRequest
{
Command = PlaystateCommand.Seek,
ControllingUserId = session.UserId.ToString(),
SeekPositionTicks = creditEnd * TimeSpan.TicksPerSecond,
},
CancellationToken.None);
// Flag that we've sent the seek command so that it's not sent repeatedly
lock (_sentSeekCommandLock)
{
_logger.LogTrace("Setting seek command state for session {Session}", deviceId);
_sentSeekCommand[deviceId] = true;
}
}
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="disposing">Dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
_playbackTimer.Stop();
_playbackTimer.Dispose();
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Setting up automatic credit skipping");
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged;
// Make the timer restart automatically and set enabled to match the configuration value.
_playbackTimer.AutoReset = true;
_playbackTimer.Elapsed += PlaybackTimer_Elapsed;
AutoSkipCreditChanged(null, Plugin.Instance.Configuration);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
return Task.CompletedTask;
}
}

View File

@ -1,226 +0,0 @@
using System.Collections.Generic;
using System.Diagnostics;
using MediaBrowser.Model.Plugins;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public PluginConfiguration()
{
}
// ===== 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. If empty, all libraries will be analyzed.
/// </summary>
public string SelectedLibraries { get; set; } = string.Empty;
/// <summary>
/// Gets a temporary limitation on file paths to be analyzed. Should be empty when automatic scan is idle.
/// </summary>
public IList<string> PathRestrictions { get; } = new List<string>();
/// <summary>
/// Gets or sets a value indicating whether to scan for intros during a scheduled task.
/// </summary>
public bool AutoDetectIntros { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether to scan for credits during a scheduled task.
/// </summary>
public bool AutoDetectCredits { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether to analyze season 0.
/// </summary>
public bool AnalyzeSeasonZero { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem.
/// </summary>
public bool CacheFingerprints { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether analysis will use Chromaprint to determine fingerprints.
/// </summary>
public bool UseChromaprint { get; set; } = true;
// ===== EDL handling =====
/// <summary>
/// Gets or sets a value indicating the action to write to created EDL files.
/// </summary>
public EdlAction EdlAction { get; set; } = EdlAction.None;
/// <summary>
/// Gets or sets a value indicating whether to regenerate all EDL files during the next scan.
/// By default, EDL files are only written for a season if the season had at least one newly analyzed episode.
/// If this is set, all EDL files will be regenerated and overwrite any existing EDL file.
/// </summary>
public bool RegenerateEdlFiles { get; set; } = false;
// ===== Custom analysis settings =====
/// <summary>
/// Gets or sets the percentage of each episode's audio track to analyze.
/// </summary>
public int AnalysisPercent { get; set; } = 25;
/// <summary>
/// Gets or sets the upper limit (in minutes) on the length of each episode's audio track that will be analyzed.
/// </summary>
public int AnalysisLengthLimit { get; set; } = 10;
/// <summary>
/// Gets or sets the minimum length of similar audio that will be considered an introduction.
/// </summary>
public int MinimumIntroDuration { get; set; } = 15;
/// <summary>
/// Gets or sets the maximum length of similar audio that will be considered an introduction.
/// </summary>
public int MaximumIntroDuration { get; set; } = 120;
/// <summary>
/// Gets or sets the minimum length of similar audio that will be considered ending credits.
/// </summary>
public int MinimumCreditsDuration { get; set; } = 15;
/// <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;
/// <summary>
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
/// </summary>
public int BlackFrameMinimumPercentage { get; set; } = 85;
/// <summary>
/// Gets or sets the regular expression used to detect introduction chapters.
/// </summary>
public string ChapterAnalyzerIntroductionPattern { get; set; } =
@"(^|\s)(Intro|Introduction|OP|Opening)(\s|$)";
/// <summary>
/// Gets or sets the regular expression used to detect ending credit chapters.
/// </summary>
public string ChapterAnalyzerEndCreditsPattern { get; set; } =
@"(^|\s)(Credits?|ED|Ending)(\s|$)";
// ===== Playback settings =====
/// <summary>
/// Gets or sets a value indicating whether to show the skip intro button.
/// </summary>
public bool SkipButtonVisible { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether introductions should be automatically skipped.
/// </summary>
public bool AutoSkip { get; set; }
/// <summary>
/// Gets or sets a value indicating whether credits should be automatically skipped.
/// </summary>
public bool AutoSkipCredits { get; set; }
/// <summary>
/// Gets or sets the seconds before the intro starts to show the skip prompt at.
/// </summary>
public int ShowPromptAdjustment { get; set; } = 5;
/// <summary>
/// Gets or sets the seconds after the intro starts to hide the skip prompt at.
/// </summary>
public int HidePromptAdjustment { get; set; } = 10;
/// <summary>
/// Gets or sets a value indicating whether the introduction in the first episode of a season should be ignored.
/// </summary>
public bool SkipFirstEpisode { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether the skip button should be displayed for the duration of the intro.
/// </summary>
public bool PersistSkipButton { get; set; } = true;
/// <summary>
/// Gets or sets the amount of intro to play (in seconds).
/// </summary>
public int SecondsOfIntroToPlay { get; set; } = 2;
// ===== Internal algorithm settings =====
/// <summary>
/// Gets or sets the maximum number of bits (out of 32 total) that can be different between two Chromaprint points before they are considered dissimilar.
/// Defaults to 6 (81% similar).
/// </summary>
public int MaximumFingerprintPointDifferences { get; set; } = 6;
/// <summary>
/// Gets or sets the maximum number of seconds that can pass between two similar fingerprint points before a new time range is started.
/// </summary>
public double MaximumTimeSkip { get; set; } = 3.5;
/// <summary>
/// Gets or sets the amount to shift inverted indexes by.
/// </summary>
public int InvertedIndexShift { get; set; } = 2;
/// <summary>
/// Gets or sets the maximum amount of noise (in dB) that is considered silent.
/// Lowering this number will increase the filter's sensitivity to noise.
/// </summary>
public int SilenceDetectionMaximumNoise { get; set; } = -50;
/// <summary>
/// Gets or sets the minimum duration of audio (in seconds) that is considered silent.
/// </summary>
public double SilenceDetectionMinimumDuration { get; set; } = 0.33;
// ===== Localization support =====
/// <summary>
/// Gets or sets the text to display in the skip button in introduction mode.
/// </summary>
public string SkipButtonIntroText { get; set; } = "Skip Intro";
/// <summary>
/// Gets or sets the text to display in the skip button in end credits mode.
/// </summary>
public string SkipButtonEndCreditsText { get; set; } = "Next";
/// <summary>
/// Gets or sets the notification text sent after automatically skipping an introduction.
/// </summary>
public string AutoSkipNotificationText { get; set; } = "Intro skipped";
/// <summary>
/// Gets or sets the notification text sent after automatically skipping credits.
/// </summary>
public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
/// <summary>
/// Gets or sets the number of threads for an ffmpeg process.
/// </summary>
public int ProcessThreads { get; set; } = 0;
/// <summary>
/// Gets or sets the relative priority for an ffmpeg process.
/// </summary>
public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal;
}

View File

@ -1,35 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
/// <summary>
/// User interface configuration.
/// </summary>
public class UserInterfaceConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="UserInterfaceConfiguration"/> class.
/// </summary>
/// <param name="visible">Skip button visibility.</param>
/// <param name="introText">Skip button intro text.</param>
/// <param name="creditsText">Skip button end credits text.</param>
public UserInterfaceConfiguration(bool visible, string introText, string creditsText)
{
SkipButtonVisible = visible;
SkipButtonIntroText = introText;
SkipButtonEndCreditsText = creditsText;
}
/// <summary>
/// Gets or sets a value indicating whether to show the skip intro button.
/// </summary>
public bool SkipButtonVisible { get; set; }
/// <summary>
/// Gets or sets the text to display in the skip intro button in introduction mode.
/// </summary>
public string SkipButtonIntroText { get; set; }
/// <summary>
/// Gets or sets the text to display in the skip intro button in end credits mode.
/// </summary>
public string SkipButtonEndCreditsText { get; set; }
}

View File

@ -1,289 +0,0 @@
let introSkipper = {
allowEnter: true,
skipSegments: {},
videoPlayer: {},
// .bind() is used here to prevent illegal invocation errors
originalFetch: window.fetch.bind(window),
};
introSkipper.d = function (msg) {
console.debug("[intro skipper] ", msg);
}
/** Setup event listeners */
introSkipper.setup = function () {
document.addEventListener("viewshow", introSkipper.viewShow);
window.fetch = introSkipper.fetchWrapper;
introSkipper.d("Registered hooks");
}
/** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
introSkipper.fetchWrapper = async function (...args) {
// Based on JellyScrub's trickplay.js
let [resource, options] = args;
let response = await introSkipper.originalFetch(resource, options);
// Bail early if this isn't a playback info URL
try {
let path = new URL(resource).pathname;
if (!path.includes("/PlaybackInfo")) { return response; }
introSkipper.d("Retrieving skip segments from URL");
introSkipper.d(path);
// Check for context root and set id accordingly
let path_arr = path.split("/");
let id = "";
if (path_arr[1] == "Items") {
id = path_arr[2];
} else {
id = path_arr[3];
}
introSkipper.skipSegments = await introSkipper.secureFetch(`Episode/${id}/IntroSkipperSegments`);
introSkipper.d("Successfully retrieved skip segments");
introSkipper.d(introSkipper.skipSegments);
}
catch (e) {
console.error("Unable to get skip segments from", resource, e);
}
return response;
}
/**
* Event handler that runs whenever the current view changes.
* Used to detect the start of video playback.
*/
introSkipper.viewShow = function () {
const location = window.location.hash;
introSkipper.d("Location changed to " + location);
if (location !== "#/video") {
introSkipper.d("Ignoring location change");
return;
}
introSkipper.injectCss();
introSkipper.injectButton();
introSkipper.videoPlayer = document.querySelector("video");
if (introSkipper.videoPlayer != null) {
introSkipper.d("Hooking video timeupdate");
introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged);
document.body.addEventListener('keydown', introSkipper.eventHandler, true);
}
}
/**
* Injects the CSS used by the skip intro button.
* Calling this function is a no-op if the CSS has already been injected.
*/
introSkipper.injectCss = function () {
if (introSkipper.testElement("style#introSkipperCss")) {
introSkipper.d("CSS already added");
return;
}
introSkipper.d("Adding CSS");
let styleElement = document.createElement("style");
styleElement.id = "introSkipperCss";
styleElement.innerText = `
:root {
--rounding: .2em;
--accent: 0, 164, 220;
}
#skipIntro.upNextContainer {
width: unset;
}
#skipIntro {
position: absolute;
bottom: 6em;
right: 4.5em;
background-color: transparent;
font-size: 1.2em;
}
#skipIntro .emby-button {
text-shadow: 0 0 3px rgba(0, 0, 0, 0.7);
border-radius: var(--rounding);
background-color: rgba(0, 0, 0, 0.3);
will-change: opacity, transform;
opacity: 0;
transition: opacity 0.3s ease-in, transform 0.3s ease-out;
}
#skipIntro .emby-button:hover,
#skipIntro .emby-button:focus {
background-color: rgba(var(--accent),0.7);
transform: scale(1.05);
}
#btnSkipSegmentText {
padding-right: 0.15em;
padding-left: 0.2em;
margin-top: -0.1em;
}
`;
document.querySelector("head").appendChild(styleElement);
}
/**
* Inject the skip intro button into the video player.
* Calling this function is a no-op if the CSS has already been injected.
*/
introSkipper.injectButton = async function () {
// Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
const preExistingButton = introSkipper.testElement("div.skipIntro");
if (preExistingButton) {
preExistingButton.style.display = "none";
}
if (introSkipper.testElement(".btnSkipIntro.injected")) {
introSkipper.d("Button already added");
return;
}
introSkipper.d("Adding button");
let config = await introSkipper.secureFetch("Intros/UserInterfaceConfiguration");
if (!config.SkipButtonVisible) {
introSkipper.d("Not adding button: not visible");
return;
}
// Construct the skip button div
const button = document.createElement("div");
button.id = "skipIntro"
button.classList.add("hide");
button.addEventListener("click", introSkipper.doSkip);
button.innerHTML = `
<button is="emby-button" type="button" class="btnSkipIntro injected">
<span id="btnSkipSegmentText"></span>
<span class="material-icons skip_next"></span>
</button>
`;
button.dataset["intro_text"] = config.SkipButtonIntroText;
button.dataset["credits_text"] = config.SkipButtonEndCreditsText;
/*
* Alternative workaround for #44. Jellyfin's video component registers a global click handler
* (located at src/controllers/playback/video/index.js:1492) that pauses video playback unless
* the clicked element has a parent with the class "videoOsdBottom" or "upNextContainer".
*/
button.classList.add("upNextContainer");
// Append the button to the video OSD
let controls = document.querySelector("div#videoOsdPage");
controls.appendChild(button);
}
/** Tests if the OSD controls are visible. */
introSkipper.osdVisible = function () {
const osd = document.querySelector("div.videoOsdBottom");
return osd ? !osd.classList.contains("hide") : false;
}
/** Get the currently playing skippable segment. */
introSkipper.getCurrentSegment = function (position) {
for (let key in introSkipper.skipSegments) {
const segment = introSkipper.skipSegments[key];
if ((position >= segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt) || (introSkipper.osdVisible() && position >= segment.IntroStart && position < segment.IntroEnd)) {
segment["SegmentType"] = key;
return segment;
}
}
return { "SegmentType": "None" };
}
introSkipper.overrideBlur = function(embyButton) {
if (!embyButton.originalBlur) {
embyButton.originalBlur = embyButton.blur;
}
embyButton.blur = function () {
if (!introSkipper.osdVisible() || !embyButton.contains(document.activeElement)) {
embyButton.originalBlur.call(this);
}
};
};
introSkipper.restoreBlur = function(embyButton) {
if (embyButton.originalBlur) {
embyButton.blur = embyButton.originalBlur;
delete embyButton.originalBlur;
}
};
/** Playback position changed, check if the skip button needs to be displayed. */
introSkipper.videoPositionChanged = function () {
const skipButton = document.querySelector("#skipIntro");
if (introSkipper.videoPlayer.currentTime === 0 || !skipButton || !introSkipper.allowEnter) return;
const embyButton = skipButton.querySelector(".emby-button");
const tvLayout = document.documentElement.classList.contains("layout-tv");
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
switch (segment.SegmentType) {
case "None":
if (embyButton.style.opacity === '0') return;
embyButton.style.opacity = '0';
embyButton.addEventListener("transitionend", () => {
skipButton.classList.add("hide");
if (tvLayout) {
introSkipper.restoreBlur(embyButton);
embyButton.blur();
}
}, { once: true });
return;
case "Introduction":
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.intro_text;
break;
case "Credits":
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.credits_text;
break;
}
if (!skipButton.classList.contains("hide")) return;
skipButton.classList.remove("hide");
embyButton.style.opacity = '1';
if (tvLayout) {
introSkipper.overrideBlur(embyButton);
embyButton.focus({ focusVisible: true });
}
}
introSkipper.throttle = function (func, limit) {
let inThrottle;
return function(...args) {
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/** Seeks to the end of the intro. */
introSkipper.doSkip = introSkipper.throttle(function (e) {
introSkipper.d("Skipping intro");
introSkipper.d(introSkipper.skipSegments);
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
if (segment.SegmentType === "None") {
console.warn("[intro skipper] doSkip() called without an active segment");
return;
}
// Disable keydown events
introSkipper.allowEnter = false;
introSkipper.videoPlayer.currentTime = segment.IntroEnd;
// Listen for the seeked event to re-enable keydown events
const onSeeked = async () => {
await new Promise(resolve => setTimeout(resolve, 50)); // Wait 50ms
introSkipper.allowEnter = true;
introSkipper.videoPlayer.removeEventListener('seeked', onSeeked);
};
introSkipper.videoPlayer.addEventListener('seeked', onSeeked);
}, 3000);
/** Tests if an element with the provided selector exists. */
introSkipper.testElement = function (selector) { return document.querySelector(selector); }
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
introSkipper.secureFetch = async function (url) {
url = ApiClient.serverAddress() + "/" + url;
const reqInit = { headers: { "Authorization": "MediaBrowser Token=" + ApiClient.accessToken() } };
const res = await fetch(url, reqInit);
if (res.status !== 200) { throw new Error(`Expected status 200 from ${url}, but got ${res.status}`); }
return await res.json();
}
/** Handle keydown events. */
introSkipper.eventHandler = function (e) {
const skipButton = document.querySelector("#skipIntro");
if (!skipButton || skipButton.classList.contains("hide")) return;
// Ignore all keydown events
if (!introSkipper.allowEnter) {
e.preventDefault();
return;
}
if (e.key !== "Enter") return;
const embyButton = skipButton.querySelector(".emby-button");
if (document.documentElement.classList.contains("layout-tv") && embyButton.contains(document.activeElement)) {
e.stopPropagation();
return;
}
if (document.documentElement.classList.contains("layout-desktop")) {
e.preventDefault();
e.stopPropagation();
introSkipper.doSkip();
}
}
introSkipper.setup();

View File

@ -1,234 +0,0 @@
// re-render the troubleshooter with the latest offset
function renderTroubleshooter() {
paintFingerprintDiff(canvas, lhs, rhs, Number(offset.value));
findIntros();
}
// refresh the upper & lower bounds for the offset
function refreshBounds() {
const len = Math.min(lhs.length, rhs.length) - 1;
offset.min = -1 * len;
offset.max = len;
}
function findIntros() {
let times = [];
// get the times of all similar fingerprint points
for (let i in fprDiffs) {
if (fprDiffs[i] > fprDiffMinimum) {
times.push(i * 0.128);
}
}
// always close the last range
times.push(Number.MAX_VALUE);
let last = times[0];
let start = last;
let end = last;
let ranges = [];
for (let t of times) {
const diff = t - last;
if (diff <= 3.5) {
end = t;
last = t;
continue;
}
const dur = Math.round(end - start);
if (dur >= 15) {
ranges.push({
"start": start,
"end": end,
"duration": dur
});
}
start = t;
end = t;
last = t;
}
const introsLog = document.querySelector("span#intros");
introsLog.style.position = "relative";
introsLog.style.left = "115px";
introsLog.innerHTML = "";
const offset = Number(txtOffset.value) * 0.128;
for (let r of ranges) {
let lStart, lEnd, rStart, rEnd;
if (offset < 0) {
// negative offset, the diff is aligned with the RHS
lStart = r.start - offset;
lEnd = r.end - offset;
rStart = r.start;
rEnd = r.end;
} else {
// positive offset, the diff is aligned with the LHS
lStart = r.start;
lEnd = r.end;
rStart = r.start + offset;
rEnd = r.end + offset;
}
const lTitle = selectEpisode1.options[selectEpisode1.selectedIndex].text;
const rTitle = selectEpisode2.options[selectEpisode2.selectedIndex].text;
introsLog.innerHTML += "<span>" + lTitle + ": " +
secondsToString(lStart) + " - " + secondsToString(lEnd) + "</span> <br />";
introsLog.innerHTML += "<span>" + rTitle + ": " +
secondsToString(rStart) + " - " + secondsToString(rEnd) + "</span> <br />";
}
}
// find all shifts which align exact matches of audio.
function findExactMatches() {
let shifts = [];
for (let lhsIndex in lhs) {
let lhsPoint = lhs[lhsIndex];
let rhsIndex = rhs.findIndex((x) => x === lhsPoint);
if (rhsIndex === -1) {
continue;
}
let shift = rhsIndex - lhsIndex;
if (shifts.includes(shift)) {
continue;
}
shifts.push(shift);
}
// Only suggest up to 20 shifts
shifts = shifts.slice(0, 20);
txtSuggested.textContent = "Suggested shifts: ";
if (shifts.length === 0) {
txtSuggested.textContent += "none available";
} else {
shifts.sort((a, b) => { return a - b });
txtSuggested.textContent += shifts.join(", ");
}
}
// The below two functions were modified from https://github.com/dnknth/acoustid-match/blob/ffbf21d8c53c40d3b3b4c92238c35846545d3cd7/fingerprints/static/fingerprints/fputils.js
// Originally licensed as MIT.
function renderFingerprintData(ctx, fp, xor = false) {
const pixels = ctx.createImageData(32, fp.length);
let idx = 0;
for (let i = 0; i < fp.length; i++) {
for (let j = 0; j < 32; j++) {
if (fp[i] & (1 << j)) {
pixels.data[idx + 0] = 255;
pixels.data[idx + 1] = 255;
pixels.data[idx + 2] = 255;
} else {
pixels.data[idx + 0] = 0;
pixels.data[idx + 1] = 0;
pixels.data[idx + 2] = 0;
}
pixels.data[idx + 3] = 255;
idx += 4;
}
}
if (!xor) {
return pixels;
}
// if rendering the XOR of the fingerprints, count how many bits are different at each timecode
fprDiffs = [];
for (let i = 0; i < fp.length; i++) {
let count = 0;
for (let j = 0; j < 32; j++) {
if (fp[i] & (1 << j)) {
count++;
}
}
// push the percentage similarity
fprDiffs[i] = 100 - (count * 100) / 32;
}
return pixels;
}
function paintFingerprintDiff(canvas, fp1, fp2, offset) {
if (fp1.length == 0) {
return;
}
canvas.style.display = "unset";
let leftOffset = 0, rightOffset = 0;
if (offset < 0) {
leftOffset -= offset;
} else {
rightOffset += offset;
}
let fpDiff = [];
fpDiff.length = Math.min(fp1.length, fp2.length) - Math.abs(offset);
for (let i = 0; i < fpDiff.length; i++) {
fpDiff[i] = fp1[i + leftOffset] ^ fp2[i + rightOffset];
}
const ctx = canvas.getContext('2d');
const pixels1 = renderFingerprintData(ctx, fp1);
const pixels2 = renderFingerprintData(ctx, fp2);
const pixelsDiff = renderFingerprintData(ctx, fpDiff, true);
const border = 4;
canvas.width = pixels1.width + border + // left fingerprint
pixels2.width + border + // right fingerprint
pixelsDiff.width + border // fingerprint diff
+ 4; // if diff[x] >= fprDiffMinimum
canvas.height = Math.max(pixels1.height, pixels2.height) + Math.abs(offset);
ctx.rect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#C5C5C5";
ctx.fill();
// draw left fingerprint
let dx = 0;
ctx.putImageData(pixels1, dx, rightOffset);
dx += pixels1.width + border;
// draw right fingerprint
ctx.putImageData(pixels2, dx, leftOffset);
dx += pixels2.width + border;
// draw fingerprint diff
ctx.putImageData(pixelsDiff, dx, Math.abs(offset));
dx += pixelsDiff.width + border;
// draw the fingerprint diff similarity indicator
// https://davidmathlogic.com/colorblind/#%23EA3535-%232C92EF
for (let i in fprDiffs) {
const j = Number(i);
const y = Math.abs(offset) + j;
const point = fprDiffs[j];
if (point >= 100) {
ctx.fillStyle = "#002FFF"
} else if (point >= fprDiffMinimum) {
ctx.fillStyle = "#2C92EF";
} else {
ctx.fillStyle = "#EA3535";
}
ctx.fillRect(dx, y, 4, 1);
}
}

View File

@ -1,27 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace>
<AssemblyVersion>0.2.0.9</AssemblyVersion>
<FileVersion>0.2.0.9</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" />
<PackageReference Include="Jellyfin.Model" Version="10.*-*" />
<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" />
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\visualizer.js" />
<EmbeddedResource Include="Configuration\inject.js" />
<EmbeddedResource Include="Configuration\version.txt" />
</ItemGroup>
</Project>

View File

@ -1,209 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Mime;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Entities.TV;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
/// <summary>
/// Skip intro controller.
/// </summary>
[Authorize]
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
public class SkipIntroController : ControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="SkipIntroController"/> class.
/// </summary>
public SkipIntroController()
{
}
/// <summary>
/// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format.
/// </summary>
/// <param name="id">ID of the episode. Required.</param>
/// <param name="mode">Timestamps to return. Optional. Defaults to Introduction for backwards compatibility.</param>
/// <response code="200">Episode contains an intro.</response>
/// <response code="404">Failed to find an intro in the provided episode.</response>
/// <returns>Detected intro.</returns>
[HttpGet("Episode/{id}/IntroTimestamps")]
[HttpGet("Episode/{id}/IntroTimestamps/v1")]
public ActionResult<Intro> GetIntroTimestamps(
[FromRoute] Guid id,
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
{
var intro = GetIntro(id, mode);
if (intro is null || !intro.Valid)
{
return NotFound();
}
return intro;
}
/// <summary>
/// Gets a dictionary of all skippable segments.
/// </summary>
/// <param name="id">Media ID.</param>
/// <response code="200">Skippable segments dictionary.</response>
/// <returns>Dictionary of skippable segments.</returns>
[HttpGet("Episode/{id}/IntroSkipperSegments")]
public ActionResult<Dictionary<AnalysisMode, Intro>> GetSkippableSegments([FromRoute] Guid id)
{
var segments = new Dictionary<AnalysisMode, Intro>();
if (GetIntro(id, AnalysisMode.Introduction) is Intro intro)
{
segments[AnalysisMode.Introduction] = intro;
}
if (GetIntro(id, AnalysisMode.Credits) is Intro credits)
{
segments[AnalysisMode.Credits] = credits;
}
return segments;
}
/// <summary>Lookup and return the skippable timestamps for the provided item.</summary>
/// <param name="id">Unique identifier of this episode.</param>
/// <param name="mode">Mode.</param>
/// <returns>Intro object if the provided item has an intro, null otherwise.</returns>
private Intro? GetIntro(Guid id, AnalysisMode mode)
{
try
{
var timestamp = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Intros[id] :
Plugin.Instance!.Credits[id];
// Operate on a copy to avoid mutating the original Intro object stored in the dictionary.
var segment = new Intro(timestamp);
var config = Plugin.Instance.Configuration;
segment.IntroEnd -= config.SecondsOfIntroToPlay;
if (config.PersistSkipButton)
{
segment.ShowSkipPromptAt = segment.IntroStart;
segment.HideSkipPromptAt = segment.IntroEnd;
}
else
{
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
segment.HideSkipPromptAt = Math.Min(
segment.IntroStart + config.HidePromptAdjustment,
segment.IntroEnd);
}
return segment;
}
catch (KeyNotFoundException)
{
return null;
}
}
/// <summary>
/// Erases all previously discovered introduction timestamps.
/// </summary>
/// <param name="mode">Mode.</param>
/// <param name="eraseCache">Erase cache.</param>
/// <response code="204">Operation successful.</response>
/// <returns>No content.</returns>
[Authorize(Policy = Policies.RequiresElevation)]
[HttpPost("Intros/EraseTimestamps")]
public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false)
{
if (mode == AnalysisMode.Introduction)
{
Plugin.Instance!.Intros.Clear();
}
else if (mode == AnalysisMode.Credits)
{
Plugin.Instance!.Credits.Clear();
}
if (eraseCache)
{
FFmpegWrapper.DeleteCacheFiles(mode);
}
Plugin.Instance!.SaveTimestamps(mode);
return NoContent();
}
/// <summary>
/// Erases all previously cached introduction fingerprints.
/// </summary>
/// <response code="204">Operation successful.</response>
/// <returns>No content.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpPost("Intros/CleanCache")]
public ActionResult CleanIntroCache()
{
FFmpegWrapper.CleanCacheFiles();
return NoContent();
}
/// <summary>
/// Get all introductions or credits. Only used by the end to end testing script.
/// </summary>
/// <param name="mode">Mode.</param>
/// <response code="200">All timestamps have been returned.</response>
/// <returns>List of IntroWithMetadata objects.</returns>
[Authorize(Policy = Policies.RequiresElevation)]
[HttpGet("Intros/All")]
public ActionResult<List<IntroWithMetadata>> GetAllTimestamps(
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
{
List<IntroWithMetadata> intros = new();
var timestamps = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Intros :
Plugin.Instance!.Credits;
// Get metadata for all intros
foreach (var intro in timestamps)
{
// Get the details of the item from Jellyfin
var rawItem = Plugin.Instance.GetItem(intro.Key);
if (rawItem == null || rawItem is not Episode episode)
{
throw new InvalidCastException("Unable to cast item id " + intro.Key + " to an Episode");
}
// Associate the metadata with the intro
intros.Add(
new IntroWithMetadata(
episode.SeriesName,
episode.AiredSeasonNumber ?? 0,
episode.Name,
intro.Value));
}
return intros;
}
/// <summary>
/// Gets the user interface configuration.
/// </summary>
/// <response code="200">UserInterfaceConfiguration returned.</response>
/// <returns>UserInterfaceConfiguration.</returns>
[HttpGet]
[Route("Intros/UserInterfaceConfiguration")]
public ActionResult<UserInterfaceConfiguration> GetUserInterfaceConfiguration()
{
var config = Plugin.Instance!.Configuration;
return new UserInterfaceConfiguration(
config.SkipButtonVisible,
config.SkipButtonIntroText,
config.SkipButtonEndCreditsText);
}
}

View File

@ -1,88 +0,0 @@
using System;
using System.Net.Mime;
using System.Text;
using MediaBrowser.Common;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Configuration;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
/// <summary>
/// Troubleshooting controller.
/// </summary>
[Authorize(Policy = Policies.RequiresElevation)]
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
[Route("IntroSkipper")]
public class TroubleshootingController : ControllerBase
{
private readonly IApplicationHost _applicationHost;
private readonly ILogger<TroubleshootingController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TroubleshootingController"/> class.
/// </summary>
/// <param name="applicationHost">Application host.</param>
/// <param name="logger">Logger.</param>
public TroubleshootingController(
IApplicationHost applicationHost,
ILogger<TroubleshootingController> logger)
{
_applicationHost = applicationHost;
_logger = logger;
}
/// <summary>
/// Gets a Markdown formatted support bundle.
/// </summary>
/// <response code="200">Support bundle created.</response>
/// <returns>Support bundle.</returns>
[HttpGet("SupportBundle")]
[Produces(MediaTypeNames.Text.Plain)]
public ActionResult<string> GetSupportBundle()
{
ArgumentNullException.ThrowIfNull(Plugin.Instance);
var bundle = new StringBuilder();
bundle.Append("* Jellyfin version: ");
bundle.Append(_applicationHost.ApplicationVersionString);
bundle.Append('\n');
var version = Plugin.Instance.Version.ToString(3);
try
{
var commit = Plugin.Instance.GetCommit();
if (!string.IsNullOrWhiteSpace(commit))
{
version += string.Concat("+", commit.AsSpan(0, 12));
}
}
catch (Exception ex)
{
_logger.LogWarning("Unable to append commit to version: {Exception}", ex);
}
bundle.Append("* Plugin version: ");
bundle.Append(version);
bundle.Append('\n');
bundle.Append("* Queue contents: ");
bundle.Append(Plugin.Instance.TotalQueued);
bundle.Append(" episodes, ");
bundle.Append(Plugin.Instance.TotalSeasons);
bundle.Append(" seasons\n");
bundle.Append("* Warnings: `");
bundle.Append(WarningManager.GetWarnings());
bundle.Append("`\n");
bundle.Append(FFmpegWrapper.GetChromaprintLogs());
return bundle.ToString();
}
}

View File

@ -1,209 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Mime;
using MediaBrowser.Common.Api;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
/// <summary>
/// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis.
/// </summary>
[Authorize(Policy = Policies.RequiresElevation)]
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
[Route("Intros")]
public class VisualizationController : ControllerBase
{
private readonly ILogger<VisualizationController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="VisualizationController"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public VisualizationController(ILogger<VisualizationController> logger)
{
_logger = logger;
}
/// <summary>
/// Returns all show names and seasons.
/// </summary>
/// <returns>Dictionary of show names to a list of season names.</returns>
[HttpGet("Shows")]
public ActionResult<Dictionary<string, HashSet<string>>> GetShowSeasons()
{
_logger.LogDebug("Returning season names by series");
var showSeasons = new Dictionary<string, HashSet<string>>();
// Loop through all seasons in the analysis queue
foreach (var kvp in Plugin.Instance!.QueuedMediaItems)
{
// Check that this season contains at least one episode.
var episodes = kvp.Value;
if (episodes is null || episodes.Count == 0)
{
_logger.LogDebug("Skipping season {Id} (null or empty)", kvp.Key);
continue;
}
// Peek at the top episode from this season and store the series name and season number.
var first = episodes[0];
var series = first.SeriesName;
var season = GetSeasonName(first);
// Validate the series and season before attempting to store it.
if (string.IsNullOrWhiteSpace(series) || string.IsNullOrWhiteSpace(season))
{
_logger.LogDebug("Skipping season {Id} (no name or number)", kvp.Key);
continue;
}
// TryAdd is used when adding the HashSet since it is a no-op if one was already created for this series.
showSeasons.TryAdd(series, new HashSet<string>());
showSeasons[series].Add(season);
}
return showSeasons;
}
/// <summary>
/// Returns the names and unique identifiers of all episodes in the provided season.
/// </summary>
/// <param name="series">Show name.</param>
/// <param name="season">Season name.</param>
/// <returns>List of episode titles.</returns>
[HttpGet("Show/{Series}/{Season}")]
public ActionResult<List<EpisodeVisualization>> GetSeasonEpisodes(
[FromRoute] string series,
[FromRoute] string season)
{
var visualEpisodes = new List<EpisodeVisualization>();
if (!LookupSeasonByName(series, season, out var episodes))
{
return NotFound();
}
foreach (var e in episodes)
{
visualEpisodes.Add(new EpisodeVisualization(e.EpisodeId, e.Name));
}
return visualEpisodes;
}
/// <summary>
/// Fingerprint the provided episode and returns the uncompressed fingerprint data points.
/// </summary>
/// <param name="id">Episode id.</param>
/// <returns>Read only collection of fingerprint points.</returns>
[HttpGet("Episode/{Id}/Chromaprint")]
public ActionResult<uint[]> GetEpisodeFingerprint([FromRoute] Guid id)
{
// Search through all queued episodes to find the requested id
foreach (var season in Plugin.Instance!.QueuedMediaItems)
{
foreach (var needle in season.Value)
{
if (needle.EpisodeId == id)
{
return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction);
}
}
}
return NotFound();
}
/// <summary>
/// Erases all timestamps for the provided season.
/// </summary>
/// <param name="series">Show name.</param>
/// <param name="season">Season name.</param>
/// <param name="eraseCache">Erase cache.</param>
/// <response code="204">Season timestamps erased.</response>
/// <response code="404">Unable to find season in provided series.</response>
/// <returns>No content.</returns>
[HttpDelete("Show/{Series}/{Season}")]
public ActionResult EraseSeason([FromRoute] string series, [FromRoute] string season, [FromQuery] bool eraseCache = false)
{
if (!LookupSeasonByName(series, season, out var episodes))
{
return NotFound();
}
_logger.LogInformation("Erasing timestamps for {Series} {Season} at user request", series, season);
foreach (var e in episodes)
{
Plugin.Instance!.Intros.TryRemove(e.EpisodeId, out _);
Plugin.Instance!.Credits.TryRemove(e.EpisodeId, out _);
if (eraseCache)
{
FFmpegWrapper.DeleteEpisodeCache(e.EpisodeId);
}
}
Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction);
Plugin.Instance!.SaveTimestamps(AnalysisMode.Credits);
return NoContent();
}
/// <summary>
/// Updates the timestamps for the provided episode.
/// </summary>
/// <param name="id">Episode ID to update timestamps for.</param>
/// <param name="timestamps">New introduction start and end times.</param>
/// <response code="204">New introduction timestamps saved.</response>
/// <returns>No content.</returns>
[HttpPost("Episode/{Id}/UpdateIntroTimestamps")]
public ActionResult UpdateTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps)
{
var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd);
Plugin.Instance!.Intros[id] = new Intro(id, tr);
Plugin.Instance.SaveTimestamps(AnalysisMode.Introduction);
return NoContent();
}
private string GetSeasonName(QueuedEpisode episode)
{
return "Season " + episode.SeasonNumber.ToString(CultureInfo.InvariantCulture);
}
/// <summary>
/// Lookup a named season of a series and return all queued episodes.
/// </summary>
/// <param name="series">Series name.</param>
/// <param name="season">Season name.</param>
/// <param name="episodes">Episodes.</param>
/// <returns>Boolean indicating if the requested season was found.</returns>
private bool LookupSeasonByName(string series, string season, out List<QueuedEpisode> episodes)
{
foreach (var queuedEpisodes in Plugin.Instance!.QueuedMediaItems)
{
var first = queuedEpisodes.Value[0];
var firstSeasonName = GetSeasonName(first);
// Assert that the queued episode series and season are equal to what was requested
if (
!string.Equals(first.SeriesName, series, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(firstSeasonName, season, StringComparison.OrdinalIgnoreCase))
{
continue;
}
episodes = queuedEpisodes.Value;
return true;
}
episodes = new List<QueuedEpisode>();
return false;
}
}

View File

@ -1,17 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Type of media file analysis to perform.
/// </summary>
public enum AnalysisMode
{
/// <summary>
/// Detect introduction sequences.
/// </summary>
Introduction,
/// <summary>
/// Detect credits.
/// </summary>
Credits,
}

View File

@ -1,28 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// A frame of video that partially (or entirely) consists of black pixels.
/// </summary>
public class BlackFrame
{
/// <summary>
/// Initializes a new instance of the <see cref="BlackFrame"/> class.
/// </summary>
/// <param name="percent">Percentage of the frame that is black.</param>
/// <param name="time">Time this frame appears at.</param>
public BlackFrame(int percent, double time)
{
Percentage = percent;
Time = time;
}
/// <summary>
/// Gets or sets the percentage of the frame that is black.
/// </summary>
public int Percentage { get; set; }
/// <summary>
/// Gets or sets the time (in seconds) this frame appeared at.
/// </summary>
public double Time { get; set; }
}

View File

@ -1,42 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Taken from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL.
/// </summary>
public enum EdlAction
{
/// <summary>
/// Do not create EDL files.
/// </summary>
None = -1,
/// <summary>
/// Completely remove the intro from playback as if it was never in the original video.
/// </summary>
Cut,
/// <summary>
/// Mute audio, continue playback.
/// </summary>
Mute,
/// <summary>
/// Inserts a new scene marker.
/// </summary>
SceneMarker,
/// <summary>
/// Automatically skip the intro once during playback.
/// </summary>
CommercialBreak,
/// <summary>
/// Show a skip button.
/// </summary>
Intro,
/// <summary>
/// Show a skip button.
/// </summary>
Credit,
}

View File

@ -1,30 +0,0 @@
using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Episode name and internal ID as returned by the visualization controller.
/// </summary>
public class EpisodeVisualization
{
/// <summary>
/// Initializes a new instance of the <see cref="EpisodeVisualization"/> class.
/// </summary>
/// <param name="id">Episode id.</param>
/// <param name="name">Episode name.</param>
public EpisodeVisualization(Guid id, string name)
{
Id = id;
Name = name;
}
/// <summary>
/// Gets the id.
/// </summary>
public Guid Id { get; private set; }
/// <summary>
/// Gets the name.
/// </summary>
public string Name { get; private set; } = string.Empty;
}

View File

@ -1,33 +0,0 @@
using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Exception raised when an error is encountered analyzing audio.
/// </summary>
public class FingerprintException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="FingerprintException"/> class.
/// </summary>
public FingerprintException()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FingerprintException"/> class.
/// </summary>
/// <param name="message">Exception message.</param>
public FingerprintException(string message) : base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FingerprintException"/> class.
/// </summary>
/// <param name="message">Exception message.</param>
/// <param name="inner">Inner exception.</param>
public FingerprintException(string message, Exception inner) : base(message, inner)
{
}
}

View File

@ -1,147 +0,0 @@
using System;
using System.Globalization;
using System.Text.Json.Serialization;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Result of fingerprinting and analyzing two episodes in a season.
/// All times are measured in seconds relative to the beginning of the media file.
/// </summary>
public class Intro
{
/// <summary>
/// Initializes a new instance of the <see cref="Intro"/> class.
/// </summary>
/// <param name="episode">Episode.</param>
/// <param name="intro">Introduction time range.</param>
public Intro(Guid episode, TimeRange intro)
{
EpisodeId = episode;
IntroStart = intro.Start;
IntroEnd = intro.End;
}
/// <summary>
/// Initializes a new instance of the <see cref="Intro"/> class.
/// </summary>
/// <param name="episode">Episode.</param>
public Intro(Guid episode)
{
EpisodeId = episode;
IntroStart = 0;
IntroEnd = 0;
}
/// <summary>
/// Initializes a new instance of the <see cref="Intro"/> class.
/// </summary>
/// <param name="intro">intro.</param>
public Intro(Intro intro)
{
EpisodeId = intro.EpisodeId;
IntroStart = intro.IntroStart;
IntroEnd = intro.IntroEnd;
}
/// <summary>
/// Initializes a new instance of the <see cref="Intro"/> class.
/// </summary>
public Intro()
{
}
/// <summary>
/// Gets or sets the Episode ID.
/// </summary>
public Guid EpisodeId { get; set; }
/// <summary>
/// Gets a value indicating whether this introduction is valid or not.
/// Invalid results must not be returned through the API.
/// </summary>
public bool Valid => IntroEnd > 0;
/// <summary>
/// Gets the duration of this intro.
/// </summary>
[JsonIgnore]
public double Duration => IntroEnd - IntroStart;
/// <summary>
/// Gets or sets the introduction sequence start time.
/// </summary>
public double IntroStart { get; set; }
/// <summary>
/// Gets or sets the introduction sequence end time.
/// </summary>
public double IntroEnd { get; set; }
/// <summary>
/// Gets or sets the recommended time to display the skip intro prompt.
/// </summary>
public double ShowSkipPromptAt { get; set; }
/// <summary>
/// Gets or sets the recommended time to hide the skip intro prompt.
/// </summary>
public double HideSkipPromptAt { get; set; }
/// <summary>
/// Convert this Intro object to a Kodi compatible EDL entry.
/// </summary>
/// <param name="action">User specified configuration EDL action.</param>
/// <returns>String.</returns>
public string ToEdl(EdlAction action)
{
if (action == EdlAction.None)
{
throw new ArgumentException("Cannot serialize an EdlAction of None");
}
var start = Math.Round(IntroStart, 2);
var end = Math.Round(IntroEnd, 2);
return string.Format(CultureInfo.InvariantCulture, "{0} {1} {2}", start, end, (int)action);
}
}
/// <summary>
/// An Intro class with episode metadata. Only used in end to end testing programs.
/// </summary>
public class IntroWithMetadata : Intro
{
/// <summary>
/// Initializes a new instance of the <see cref="IntroWithMetadata"/> class.
/// </summary>
/// <param name="series">Series name.</param>
/// <param name="season">Season number.</param>
/// <param name="title">Episode title.</param>
/// <param name="intro">Intro timestamps.</param>
public IntroWithMetadata(string series, int season, string title, Intro intro)
{
Series = series;
Season = season;
Title = title;
EpisodeId = intro.EpisodeId;
IntroStart = intro.IntroStart;
IntroEnd = intro.IntroEnd;
}
/// <summary>
/// Gets or sets the series name of the TV episode associated with this intro.
/// </summary>
public string Series { get; set; }
/// <summary>
/// Gets or sets the season number of the TV episode associated with this intro.
/// </summary>
public int Season { get; set; }
/// <summary>
/// Gets or sets the title of the TV episode associated with this intro.
/// </summary>
public string Title { get; set; }
}

View File

@ -1,64 +0,0 @@
using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Support bundle warning.
/// </summary>
[Flags]
public enum PluginWarning
{
/// <summary>
/// No warnings have been added.
/// </summary>
None = 0,
/// <summary>
/// Attempted to add skip button to web interface, but was unable to.
/// </summary>
UnableToAddSkipButton = 1,
/// <summary>
/// At least one media file on the server was unable to be fingerprinted by Chromaprint.
/// </summary>
InvalidChromaprintFingerprint = 2,
/// <summary>
/// The version of ffmpeg installed on the system is not compatible with the plugin.
/// </summary>
IncompatibleFFmpegBuild = 4,
}
/// <summary>
/// Warning manager.
/// </summary>
public static class WarningManager
{
private static PluginWarning warnings;
/// <summary>
/// Set warning.
/// </summary>
/// <param name="warning">Warning.</param>
public static void SetFlag(PluginWarning warning)
{
warnings |= warning;
}
/// <summary>
/// Clear warnings.
/// </summary>
public static void Clear()
{
warnings = PluginWarning.None;
}
/// <summary>
/// Get warnings.
/// </summary>
/// <returns>Warnings.</returns>
public static string GetWarnings()
{
return warnings.ToString();
}
}

View File

@ -1,49 +0,0 @@
using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Episode queued for analysis.
/// </summary>
public class QueuedEpisode
{
/// <summary>
/// Gets or sets the series name.
/// </summary>
public string SeriesName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the season number.
/// </summary>
public int SeasonNumber { get; set; }
/// <summary>
/// Gets or sets the episode id.
/// </summary>
public Guid EpisodeId { get; set; }
/// <summary>
/// Gets or sets the full path to episode.
/// </summary>
public string Path { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the name of the episode.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
/// </summary>
public int IntroFingerprintEnd { get; set; }
/// <summary>
/// Gets or sets the timestamp (in seconds) to start looking for end credits at.
/// </summary>
public int CreditsFingerprintStart { get; set; }
/// <summary>
/// Gets or sets the total duration of this media file (in seconds).
/// </summary>
public int Duration { get; set; }
}

View File

@ -1,132 +0,0 @@
using System;
using System.Collections.Generic;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
#pragma warning disable CA1036 // Override methods on comparable types
/// <summary>
/// Range of contiguous time.
/// </summary>
public class TimeRange : IComparable
{
/// <summary>
/// Initializes a new instance of the <see cref="TimeRange"/> class.
/// </summary>
public TimeRange()
{
Start = 0;
End = 0;
}
/// <summary>
/// Initializes a new instance of the <see cref="TimeRange"/> class.
/// </summary>
/// <param name="start">Time range start.</param>
/// <param name="end">Time range end.</param>
public TimeRange(double start, double end)
{
Start = start;
End = end;
}
/// <summary>
/// Initializes a new instance of the <see cref="TimeRange"/> class.
/// </summary>
/// <param name="original">Original TimeRange.</param>
public TimeRange(TimeRange original)
{
Start = original.Start;
End = original.End;
}
/// <summary>
/// Gets or sets the time range start (in seconds).
/// </summary>
public double Start { get; set; }
/// <summary>
/// Gets or sets the time range end (in seconds).
/// </summary>
public double End { get; set; }
/// <summary>
/// Gets the duration of this time range (in seconds).
/// </summary>
public double Duration => End - Start;
/// <summary>
/// Compare TimeRange durations.
/// </summary>
/// <param name="obj">Object to compare with.</param>
/// <returns>int.</returns>
public int CompareTo(object? obj)
{
if (!(obj is TimeRange tr))
{
throw new ArgumentException("obj must be a TimeRange");
}
return tr.Duration.CompareTo(Duration);
}
/// <summary>
/// Tests if this TimeRange object intersects the provided TimeRange.
/// </summary>
/// <param name="tr">Second TimeRange object to test.</param>
/// <returns>true if tr intersects the current TimeRange, false otherwise.</returns>
public bool Intersects(TimeRange tr)
{
return
(Start < tr.Start && tr.Start < End) ||
(Start < tr.End && tr.End < End);
}
}
#pragma warning restore CA1036
/// <summary>
/// Time range helpers.
/// </summary>
public static class TimeRangeHelpers
{
/// <summary>
/// Finds the longest contiguous time range.
/// </summary>
/// <param name="times">Sorted timestamps to search.</param>
/// <param name="maximumDistance">Maximum distance permitted between contiguous timestamps.</param>
/// <returns>The longest contiguous time range (if one was found), or null (if none was found).</returns>
public static TimeRange? FindContiguous(double[] times, double maximumDistance)
{
if (times.Length == 0)
{
return null;
}
Array.Sort(times);
var ranges = new List<TimeRange>();
var currentRange = new TimeRange(times[0], times[0]);
// For all provided timestamps, check if it is contiguous with its neighbor.
for (var i = 0; i < times.Length - 1; i++)
{
var current = times[i];
var next = times[i + 1];
if (next - current <= maximumDistance)
{
currentRange.End = next;
continue;
}
ranges.Add(new TimeRange(currentRange));
currentRange = new TimeRange(next, next);
}
// Find and return the longest contiguous range.
ranges.Sort();
return (ranges.Count > 0) ? ranges[0] : null;
}
}

View File

@ -1,122 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Update EDL files associated with a list of episodes.
/// </summary>
public static class EdlManager
{
private static ILogger? _logger;
/// <summary>
/// Initialize EDLManager with a logger.
/// </summary>
/// <param name="logger">ILogger.</param>
public static void Initialize(ILogger logger)
{
_logger = logger;
}
/// <summary>
/// Logs the configuration that will be used during EDL file creation.
/// </summary>
public static void LogConfiguration()
{
if (_logger is null)
{
throw new InvalidOperationException("Logger must not be null");
}
var config = Plugin.Instance!.Configuration;
if (config.EdlAction == EdlAction.None)
{
_logger.LogDebug("EDL action: None - taking no further action");
return;
}
_logger.LogDebug("EDL action: {Action}", config.EdlAction);
_logger.LogDebug("Regenerate EDL files: {Regenerate}", config.RegenerateEdlFiles);
}
/// <summary>
/// If the EDL action is set to a value other than None, update EDL files for the provided episodes.
/// </summary>
/// <param name="episodes">Episodes to update EDL files for.</param>
public static void UpdateEDLFiles(ReadOnlyCollection<QueuedEpisode> episodes)
{
var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles;
var action = Plugin.Instance.Configuration.EdlAction;
if (action == EdlAction.None)
{
_logger?.LogDebug("EDL action is set to none, not updating EDL files");
return;
}
_logger?.LogDebug("Updating EDL files with action {Action}", action);
foreach (var episode in episodes)
{
var id = episode.EpisodeId;
bool hasIntro = Plugin.Instance!.Intros.TryGetValue(id, out var intro) && intro.Valid;
bool hasCredit = Plugin.Instance!.Credits.TryGetValue(id, out var credit) && credit.Valid;
if (!hasIntro && !hasCredit)
{
_logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id);
continue;
}
var edlPath = GetEdlPath(Plugin.Instance.GetItemPath(id));
_logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath);
if (!regenerate && File.Exists(edlPath))
{
_logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath);
continue;
}
var edlContent = string.Empty;
if (hasIntro)
{
edlContent += intro?.ToEdl(action);
}
if (hasCredit)
{
if (edlContent.Length > 0)
{
edlContent += Environment.NewLine;
}
if (action == EdlAction.Intro)
{
edlContent += credit?.ToEdl(EdlAction.Credit);
}
else
{
edlContent += credit?.ToEdl(action);
}
}
File.WriteAllText(edlPath, edlContent);
}
}
/// <summary>
/// Given the path to an episode, return the path to the associated EDL file.
/// </summary>
/// <param name="mediaPath">Full path to episode.</param>
/// <returns>Full path to EDL file.</returns>
public static string GetEdlPath(string mediaPath)
{
return Path.ChangeExtension(mediaPath, "edl");
}
}

View File

@ -1,364 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Server entrypoint.
/// </summary>
public class Entrypoint : IHostedService, IDisposable
{
private readonly IUserManager _userManager;
private readonly IUserViewManager _userViewManager;
private readonly ITaskManager _taskManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<Entrypoint> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly object _pathRestrictionsLock = new();
private Timer _queueTimer;
private bool _analyzeAgain;
private List<string> _pathRestrictions = new List<string>();
private static CancellationTokenSource? _cancellationTokenSource;
private static ManualResetEventSlim _autoTaskCompletEvent = new ManualResetEventSlim(false);
/// <summary>
/// Initializes a new instance of the <see cref="Entrypoint"/> class.
/// </summary>
/// <param name="userManager">User manager.</param>
/// <param name="userViewManager">User view manager.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="taskManager">Task manager.</param>
/// <param name="logger">Logger.</param>
/// <param name="loggerFactory">Logger factory.</param>
public Entrypoint(
IUserManager userManager,
IUserViewManager userViewManager,
ILibraryManager libraryManager,
ITaskManager taskManager,
ILogger<Entrypoint> logger,
ILoggerFactory loggerFactory)
{
_userManager = userManager;
_userViewManager = userViewManager;
_libraryManager = libraryManager;
_taskManager = taskManager;
_logger = logger;
_loggerFactory = loggerFactory;
_queueTimer = new Timer(
OnTimerCallback,
null,
Timeout.InfiniteTimeSpan,
Timeout.InfiniteTimeSpan);
}
/// <summary>
/// Gets State of the automatic task.
/// </summary>
public static TaskState AutomaticTaskState
{
get
{
if (_cancellationTokenSource is not null)
{
return _cancellationTokenSource.IsCancellationRequested
? TaskState.Cancelling
: TaskState.Running;
}
return TaskState.Idle;
}
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_libraryManager.ItemAdded += OnItemAdded;
_libraryManager.ItemUpdated += OnItemModified;
_taskManager.TaskCompleted += OnLibraryRefresh;
FFmpegWrapper.Logger = _logger;
try
{
// Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible
_logger.LogInformation("Running startup enqueue");
var queueManager = new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager);
queueManager?.GetMediaItems();
}
catch (Exception ex)
{
_logger.LogError("Unable to run startup enqueue: {Exception}", ex);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_libraryManager.ItemAdded -= OnItemAdded;
_libraryManager.ItemUpdated -= OnItemModified;
_taskManager.TaskCompleted -= OnLibraryRefresh;
// Stop the timer
_queueTimer.Change(Timeout.Infinite, 0);
if (_cancellationTokenSource != null) // Null Check
{
_cancellationTokenSource.Dispose();
_cancellationTokenSource = null;
}
return Task.CompletedTask;
}
// Disclose source for inspiration
// Implementation based on the principles of jellyfin-plugin-media-analyzer:
// https://github.com/endrl/jellyfin-plugin-media-analyzer
/// <summary>
/// Library item was added.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
private void OnItemAdded(object? sender, ItemChangeEventArgs itemChangeEventArgs)
{
// Don't do anything if auto detection is disabled
if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance.Configuration.AutoDetectCredits)
{
return;
}
// Don't do anything if it's not a supported media type
if (itemChangeEventArgs.Item is not Episode)
{
return;
}
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
{
return;
}
lock (_pathRestrictionsLock)
{
_pathRestrictions.Add(itemChangeEventArgs.Item.ContainingFolderPath);
}
StartTimer();
}
/// <summary>
/// Library item was modified.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
private void OnItemModified(object? sender, ItemChangeEventArgs itemChangeEventArgs)
{
// Don't do anything if auto detection is disabled
if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance.Configuration.AutoDetectCredits)
{
return;
}
// Don't do anything if it's not a supported media type
if (itemChangeEventArgs.Item is not Episode)
{
return;
}
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
{
return;
}
lock (_pathRestrictionsLock)
{
_pathRestrictions.Add(itemChangeEventArgs.Item.ContainingFolderPath);
}
StartTimer();
}
/// <summary>
/// TaskManager task ended.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs)
{
// Don't do anything if auto detection is disabled
if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance.Configuration.AutoDetectCredits)
{
return;
}
var result = eventArgs.Result;
if (result.Key != "RefreshLibrary")
{
return;
}
if (result.Status != TaskCompletionStatus.Completed)
{
return;
}
// Unless user initiated, this is likely an overlap
if (AutomaticTaskState == TaskState.Running)
{
return;
}
StartTimer();
}
/// <summary>
/// Start timer to debounce analyzing.
/// </summary>
private void StartTimer()
{
if (AutomaticTaskState == TaskState.Running)
{
_analyzeAgain = true;
}
else if (AutomaticTaskState == TaskState.Idle)
{
_logger.LogDebug("Media Library changed, analyzis will start soon!");
_queueTimer.Change(TimeSpan.FromMilliseconds(20000), Timeout.InfiniteTimeSpan);
}
}
/// <summary>
/// Wait for timer callback to be completed.
/// </summary>
private void OnTimerCallback(object? state)
{
try
{
PerformAnalysis();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in PerformAnalysis");
}
// Clean up
Plugin.Instance!.Configuration.PathRestrictions.Clear();
_cancellationTokenSource = null;
_autoTaskCompletEvent.Set();
}
/// <summary>
/// Wait for timer to be completed.
/// </summary>
private void PerformAnalysis()
{
_logger.LogInformation("Initiate automatic analysis task.");
_autoTaskCompletEvent.Reset();
using (_cancellationTokenSource = new CancellationTokenSource())
using (ScheduledTaskSemaphore.Acquire(-1, _cancellationTokenSource.Token))
{
lock (_pathRestrictionsLock)
{
foreach (var path in _pathRestrictions)
{
Plugin.Instance!.Configuration.PathRestrictions.Add(path);
}
_pathRestrictions.Clear();
}
_analyzeAgain = false;
var progress = new Progress<double>();
var modes = new List<AnalysisMode>();
var tasklogger = _loggerFactory.CreateLogger("DefaultLogger");
if (Plugin.Instance!.Configuration.AutoDetectIntros && Plugin.Instance.Configuration.AutoDetectCredits)
{
modes.Add(AnalysisMode.Introduction);
modes.Add(AnalysisMode.Credits);
tasklogger = _loggerFactory.CreateLogger<DetectIntrosCreditsTask>();
}
else if (Plugin.Instance.Configuration.AutoDetectIntros)
{
modes.Add(AnalysisMode.Introduction);
tasklogger = _loggerFactory.CreateLogger<DetectIntrosTask>();
}
else if (Plugin.Instance.Configuration.AutoDetectCredits)
{
modes.Add(AnalysisMode.Credits);
tasklogger = _loggerFactory.CreateLogger<DetectCreditsTask>();
}
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
modes.AsReadOnly(),
tasklogger,
_loggerFactory,
_libraryManager);
baseCreditAnalyzer.AnalyzeItems(progress, _cancellationTokenSource.Token);
// New item detected, start timer again
if (_analyzeAgain && !_cancellationTokenSource.IsCancellationRequested)
{
_logger.LogInformation("Analyzing ended, but we need to analyze again!");
StartTimer();
}
}
}
/// <summary>
/// Method to cancel the automatic task.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public static void CancelAutomaticTask(CancellationToken cancellationToken)
{
if (_cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested)
{
try
{
_cancellationTokenSource.Cancel();
}
catch (ObjectDisposedException)
{
_cancellationTokenSource = null;
}
}
_autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="dispose">Dispose.</param>
protected virtual void Dispose(bool dispose)
{
if (!dispose)
{
_queueTimer.Dispose();
}
}
}

View File

@ -1,726 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Wrapper for libchromaprint and the silencedetect filter.
/// </summary>
public static class FFmpegWrapper
{
/// <summary>
/// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
/// </summary>
private static readonly Regex SilenceDetectionExpression = new(
"silence_(?<type>start|end): (?<time>[0-9\\.]+)");
/// <summary>
/// Used with FFmpeg's blackframe filter to extract the time and percentage of black pixels.
/// </summary>
private static readonly Regex BlackFrameRegex = new("(pblack|t):[0-9.]+");
/// <summary>
/// Gets or sets the logger.
/// </summary>
public static ILogger? Logger { get; set; }
private static Dictionary<string, string> ChromaprintLogs { get; set; } = new();
private static ConcurrentDictionary<(Guid Id, AnalysisMode Mode), Dictionary<uint, int>> InvertedIndexCache { get; set; } = new();
/// <summary>
/// Check that the installed version of ffmpeg supports chromaprint.
/// </summary>
/// <returns>true if a compatible version of ffmpeg is installed, false on any error.</returns>
public static bool CheckFFmpegVersion()
{
try
{
// Always log ffmpeg's version information.
if (!CheckFFmpegRequirement(
"-version",
"ffmpeg",
"version",
"Unknown error with FFmpeg version"))
{
ChromaprintLogs["error"] = "unknown_error";
WarningManager.SetFlag(PluginWarning.IncompatibleFFmpegBuild);
return false;
}
// First, validate that the installed version of ffmpeg supports chromaprint at all.
if (!CheckFFmpegRequirement(
"-muxers",
"chromaprint",
"muxer list",
"The installed version of ffmpeg does not support chromaprint"))
{
ChromaprintLogs["error"] = "chromaprint_not_supported";
WarningManager.SetFlag(PluginWarning.IncompatibleFFmpegBuild);
return false;
}
// Second, validate that the Chromaprint muxer understands the "-fp_format raw" option.
if (!CheckFFmpegRequirement(
"-h muxer=chromaprint",
"binary raw fingerprint",
"chromaprint options",
"The installed version of ffmpeg does not support raw binary fingerprints"))
{
ChromaprintLogs["error"] = "fp_format_not_supported";
WarningManager.SetFlag(PluginWarning.IncompatibleFFmpegBuild);
return false;
}
// Third, validate that ffmpeg supports of the all required silencedetect options.
if (!CheckFFmpegRequirement(
"-h filter=silencedetect",
"noise tolerance",
"silencedetect options",
"The installed version of ffmpeg does not support the silencedetect filter"))
{
ChromaprintLogs["error"] = "silencedetect_not_supported";
WarningManager.SetFlag(PluginWarning.IncompatibleFFmpegBuild);
return false;
}
Logger?.LogDebug("Installed version of ffmpeg meets fingerprinting requirements");
ChromaprintLogs["error"] = "okay";
return true;
}
catch
{
ChromaprintLogs["error"] = "unknown_error";
WarningManager.SetFlag(PluginWarning.IncompatibleFFmpegBuild);
return false;
}
}
/// <summary>
/// Fingerprint a queued episode.
/// </summary>
/// <param name="episode">Queued episode to fingerprint.</param>
/// <param name="mode">Portion of media file to fingerprint. Introduction = first 25% / 10 minutes and Credits = last 4 minutes.</param>
/// <returns>Numerical fingerprint points.</returns>
public static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode)
{
int start, end;
if (mode == AnalysisMode.Introduction)
{
start = 0;
end = episode.IntroFingerprintEnd;
}
else if (mode == AnalysisMode.Credits)
{
start = episode.CreditsFingerprintStart;
end = episode.Duration;
}
else
{
throw new ArgumentException("Unknown analysis mode " + mode);
}
return Fingerprint(episode, mode, start, end);
}
/// <summary>
/// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at.
/// </summary>
/// <param name="id">Episode ID.</param>
/// <param name="fingerprint">Chromaprint fingerprint.</param>
/// <param name="mode">Mode.</param>
/// <returns>Inverted index.</returns>
public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint, AnalysisMode mode)
{
if (InvertedIndexCache.TryGetValue((id, mode), out var cached))
{
return cached;
}
var invIndex = new Dictionary<uint, int>();
for (int i = 0; i < fingerprint.Length; i++)
{
// Get the current point.
var point = fingerprint[i];
// Append the current sample's timecode to the collection for this point.
invIndex[point] = i;
}
InvertedIndexCache[(id, mode)] = invIndex;
return invIndex;
}
/// <summary>
/// Detect ranges of silence in the provided episode.
/// </summary>
/// <param name="episode">Queued episode.</param>
/// <param name="limit">Maximum amount of audio (in seconds) to detect silence in.</param>
/// <returns>Array of TimeRange objects that are silent in the queued episode.</returns>
public static TimeRange[] DetectSilence(QueuedEpisode episode, int limit)
{
Logger?.LogTrace(
"Detecting silence in \"{File}\" (limit {Limit}, id {Id})",
episode.Path,
limit,
episode.EpisodeId);
// -vn, -sn, -dn: ignore video, subtitle, and data tracks
var args = string.Format(
CultureInfo.InvariantCulture,
"-vn -sn -dn " +
"-i \"{0}\" -to {1} -af \"silencedetect=noise={2}dB:duration=0.1\" -f null -",
episode.Path,
limit,
Plugin.Instance?.Configuration.SilenceDetectionMaximumNoise ?? -50);
// Cache the output of this command to "GUID-intro-silence-v1"
var cacheKey = episode.EpisodeId.ToString("N") + "-intro-silence-v1";
var currentRange = new TimeRange();
var silenceRanges = new List<TimeRange>();
/* Each match will have a type (either "start" or "end") and a timecode (a double).
*
* Sample output:
* [silencedetect @ 0x000000000000] silence_start: 12.34
* [silencedetect @ 0x000000000000] silence_end: 56.123 | silence_duration: 43.783
*/
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
foreach (Match match in SilenceDetectionExpression.Matches(raw))
{
var isStart = match.Groups["type"].Value == "start";
var time = Convert.ToDouble(match.Groups["time"].Value, CultureInfo.InvariantCulture);
if (isStart)
{
currentRange.Start = time;
}
else
{
currentRange.End = time;
silenceRanges.Add(new TimeRange(currentRange));
}
}
return silenceRanges.ToArray();
}
/// <summary>
/// Finds the location of all black frames in a media file within a time range.
/// </summary>
/// <param name="episode">Media file to analyze.</param>
/// <param name="range">Time range to search.</param>
/// <param name="minimum">Percentage of the frame that must be black.</param>
/// <returns>Array of frames that are mostly black.</returns>
public static BlackFrame[] DetectBlackFrames(
QueuedEpisode episode,
TimeRange range,
int minimum)
{
// Seek to the start of the time range and find frames that are at least 50% black.
var args = string.Format(
CultureInfo.InvariantCulture,
"-ss {0} -i \"{1}\" -to {2} -an -dn -sn -vf \"blackframe=amount=50\" -f null -",
range.Start,
episode.Path,
range.End - range.Start);
// Cache the results to GUID-blackframes-START-END-v1.
var cacheKey = string.Format(
CultureInfo.InvariantCulture,
"{0}-blackframes-{1}-{2}-v1",
episode.EpisodeId.ToString("N"),
range.Start,
range.End);
var blackFrames = new List<BlackFrame>();
/* Run the blackframe filter.
*
* Sample output:
* [Parsed_blackframe_0 @ 0x0000000] frame:1 pblack:99 pts:43 t:0.043000 type:B last_keyframe:0
* [Parsed_blackframe_0 @ 0x0000000] frame:2 pblack:99 pts:85 t:0.085000 type:B last_keyframe:0
*/
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
foreach (var line in raw.Split('\n'))
{
// There is no FFmpeg flag to hide metadata such as description
// In our case, the metadata contained something that matched the regex.
if (line.StartsWith("[Parsed_blackframe_", StringComparison.OrdinalIgnoreCase))
{
var matches = BlackFrameRegex.Matches(line);
if (matches.Count != 2)
{
continue;
}
var (strPercent, strTime) = (
matches[0].Value.Split(':')[1],
matches[1].Value.Split(':')[1]
);
var bf = new BlackFrame(
Convert.ToInt32(strPercent, CultureInfo.InvariantCulture),
Convert.ToDouble(strTime, CultureInfo.InvariantCulture));
if (bf.Percentage > minimum)
{
blackFrames.Add(bf);
}
}
}
return blackFrames.ToArray();
}
/// <summary>
/// Gets Chromaprint debugging logs.
/// </summary>
/// <returns>Markdown formatted logs.</returns>
public static string GetChromaprintLogs()
{
// Print the FFmpeg detection status at the top.
// Format: "* FFmpeg: `error`"
// Append two newlines to separate the bulleted list from the logs
var logs = string.Format(
CultureInfo.InvariantCulture,
"* FFmpeg: `{0}`\n\n",
ChromaprintLogs["error"]);
// Always include ffmpeg version information
logs += FormatFFmpegLog("version");
// Don't print feature detection logs if the plugin started up okay
if (ChromaprintLogs["error"] == "okay")
{
return logs;
}
// Print all remaining logs
foreach (var kvp in ChromaprintLogs)
{
if (kvp.Key == "error" || kvp.Key == "version")
{
continue;
}
logs += FormatFFmpegLog(kvp.Key);
}
return logs;
}
/// <summary>
/// Run an FFmpeg command with the provided arguments and validate that the output contains
/// the provided string.
/// </summary>
/// <param name="arguments">Arguments to pass to FFmpeg.</param>
/// <param name="mustContain">String that the output must contain. Case insensitive.</param>
/// <param name="bundleName">Support bundle key to store FFmpeg's output under.</param>
/// <param name="errorMessage">Error message to log if this requirement is not met.</param>
/// <returns>true on success, false on error.</returns>
private static bool CheckFFmpegRequirement(
string arguments,
string mustContain,
string bundleName,
string errorMessage)
{
Logger?.LogDebug("Checking FFmpeg requirement {Arguments}", arguments);
var output = Encoding.UTF8.GetString(GetOutput(arguments, string.Empty, false, 2000));
Logger?.LogTrace("Output of ffmpeg {Arguments}: {Output}", arguments, output);
ChromaprintLogs[bundleName] = output;
if (!output.Contains(mustContain, StringComparison.OrdinalIgnoreCase))
{
Logger?.LogError("{ErrorMessage}", errorMessage);
return false;
}
Logger?.LogDebug("FFmpeg requirement {Arguments} met", arguments);
return true;
}
/// <summary>
/// Runs ffmpeg and returns standard output (or error).
/// If caching is enabled, will use cacheFilename to cache the output of this command.
/// </summary>
/// <param name="args">Arguments to pass to ffmpeg.</param>
/// <param name="cacheFilename">Filename to cache the output of this command to, or string.Empty if this command should not be cached.</param>
/// <param name="stderr">If standard error should be returned.</param>
/// <param name="timeout">Timeout (in miliseconds) to wait for ffmpeg to exit.</param>
private static ReadOnlySpan<byte> GetOutput(
string args,
string cacheFilename,
bool stderr = false,
int timeout = 60 * 1000)
{
var ffmpegPath = Plugin.Instance?.FFmpegPath ?? "ffmpeg";
// The silencedetect and blackframe filters output data at the info log level.
var useInfoLevel = args.Contains("silencedetect", StringComparison.OrdinalIgnoreCase) ||
args.Contains("blackframe", StringComparison.OrdinalIgnoreCase);
var logLevel = useInfoLevel ? "info" : "warning";
var cacheOutput =
(Plugin.Instance?.Configuration.CacheFingerprints ?? false) &&
!string.IsNullOrEmpty(cacheFilename);
// If caching is enabled, try to load the output of this command from the cached file.
if (cacheOutput)
{
// Calculate the absolute path to the cached file.
cacheFilename = Path.Join(Plugin.Instance!.FingerprintCachePath, cacheFilename);
// If the cached file exists, return whatever it holds.
if (File.Exists(cacheFilename))
{
Logger?.LogTrace("Returning contents of cache {Cache}", cacheFilename);
return File.ReadAllBytes(cacheFilename);
}
Logger?.LogTrace("Not returning contents of cache {Cache} (not found)", cacheFilename);
}
// Prepend some flags to prevent FFmpeg from logging it's banner and progress information
// for each file that is fingerprinted.
var prependArgument = string.Format(
CultureInfo.InvariantCulture,
"-hide_banner -loglevel {0} -threads {1} ",
logLevel,
Plugin.Instance?.Configuration.ProcessThreads ?? 0);
var info = new ProcessStartInfo(ffmpegPath, args.Insert(0, prependArgument))
{
WindowStyle = ProcessWindowStyle.Hidden,
CreateNoWindow = true,
UseShellExecute = false,
ErrorDialog = false,
RedirectStandardOutput = !stderr,
RedirectStandardError = stderr
};
var ffmpeg = new Process
{
StartInfo = info
};
Logger?.LogDebug(
"Starting ffmpeg with the following arguments: {Arguments}",
ffmpeg.StartInfo.Arguments);
ffmpeg.Start();
try
{
ffmpeg.PriorityClass = Plugin.Instance?.Configuration.ProcessPriority ?? ProcessPriorityClass.BelowNormal;
}
catch (Exception e)
{
Logger?.LogDebug(
"ffmpeg priority could not be modified. {Message}",
e.Message);
}
using (MemoryStream ms = new MemoryStream())
{
var buf = new byte[4096];
var bytesRead = 0;
do
{
var streamReader = stderr ? ffmpeg.StandardError : ffmpeg.StandardOutput;
bytesRead = streamReader.BaseStream.Read(buf, 0, buf.Length);
ms.Write(buf, 0, bytesRead);
}
while (bytesRead > 0);
ffmpeg.WaitForExit(timeout);
var output = ms.ToArray();
// If caching is enabled, cache the output of this command.
if (cacheOutput)
{
File.WriteAllBytes(cacheFilename, output);
}
return output;
}
}
/// <summary>
/// Fingerprint a queued episode.
/// </summary>
/// <param name="episode">Queued episode to fingerprint.</param>
/// <param name="mode">Portion of media file to fingerprint.</param>
/// <param name="start">Time (in seconds) relative to the start of the file to start fingerprinting from.</param>
/// <param name="end">Time (in seconds) relative to the start of the file to stop fingerprinting at.</param>
/// <returns>Numerical fingerprint points.</returns>
private static uint[] Fingerprint(QueuedEpisode episode, AnalysisMode mode, int start, int end)
{
// Try to load this episode from cache before running ffmpeg.
if (LoadCachedFingerprint(episode, mode, out uint[] cachedFingerprint))
{
Logger?.LogTrace("Fingerprint cache hit on {File}", episode.Path);
return cachedFingerprint;
}
Logger?.LogDebug(
"Fingerprinting [{Start}, {End}] from \"{File}\" (id {Id})",
start,
end,
episode.Path,
episode.EpisodeId);
var args = string.Format(
CultureInfo.InvariantCulture,
"-ss {0} -i \"{1}\" -to {2} -ac 2 -f chromaprint -fp_format raw -",
start,
episode.Path,
end - start);
// Returns all fingerprint points as raw 32 bit unsigned integers (little endian).
var rawPoints = GetOutput(args, string.Empty);
if (rawPoints.Length == 0 || rawPoints.Length % 4 != 0)
{
Logger?.LogWarning("Chromaprint returned {Count} points for \"{Path}\"", rawPoints.Length, episode.Path);
throw new FingerprintException("chromaprint output for \"" + episode.Path + "\" was malformed");
}
var results = new List<uint>();
for (var i = 0; i < rawPoints.Length; i += 4)
{
var rawPoint = rawPoints.Slice(i, 4);
results.Add(BitConverter.ToUInt32(rawPoint));
}
// Try to cache this fingerprint.
CacheFingerprint(episode, mode, results);
return results.ToArray();
}
/// <summary>
/// Tries to load an episode's fingerprint from cache. If caching is not enabled, calling this function is a no-op.
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
/// </summary>
/// <param name="episode">Episode to try to load from cache.</param>
/// <param name="mode">Analysis mode.</param>
/// <param name="fingerprint">Array to store the fingerprint in.</param>
/// <returns>true if the episode was successfully loaded from cache, false on any other error.</returns>
private static bool LoadCachedFingerprint(
QueuedEpisode episode,
AnalysisMode mode,
out uint[] fingerprint)
{
fingerprint = Array.Empty<uint>();
// If fingerprint caching isn't enabled, don't try to load anything.
if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false))
{
return false;
}
var path = GetFingerprintCachePath(episode, mode);
// If this episode isn't cached, bail out.
if (!File.Exists(path))
{
return false;
}
var raw = File.ReadAllLines(path, Encoding.UTF8);
var result = new List<uint>();
// Read each stringified uint.
result.EnsureCapacity(raw.Length);
try
{
foreach (var rawNumber in raw)
{
result.Add(Convert.ToUInt32(rawNumber, CultureInfo.InvariantCulture));
}
}
catch (FormatException)
{
// Occurs when the cached fingerprint is corrupt.
Logger?.LogDebug(
"Cached fingerprint for {Path} ({Id}) is corrupt, ignoring cache",
episode.Path,
episode.EpisodeId);
return false;
}
fingerprint = result.ToArray();
return true;
}
/// <summary>
/// Cache an episode's fingerprint to disk. If caching is not enabled, calling this function is a no-op.
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
/// </summary>
/// <param name="episode">Episode to store in cache.</param>
/// <param name="mode">Analysis mode.</param>
/// <param name="fingerprint">Fingerprint of the episode to store.</param>
private static void CacheFingerprint(
QueuedEpisode episode,
AnalysisMode mode,
List<uint> fingerprint)
{
// Bail out if caching isn't enabled.
if (!(Plugin.Instance?.Configuration.CacheFingerprints ?? false))
{
return;
}
// Stringify each data point.
var lines = new List<string>();
foreach (var number in fingerprint)
{
lines.Add(number.ToString(CultureInfo.InvariantCulture));
}
// Cache the episode.
File.WriteAllLinesAsync(
GetFingerprintCachePath(episode, mode),
lines,
Encoding.UTF8).ConfigureAwait(false);
}
/// <summary>
/// Remove a cached episode fingerprint from disk.
/// </summary>
/// <param name="id">Episode to remove from cache.</param>
public static void DeleteEpisodeCache(Guid id)
{
var cachePath = Path.Join(
Plugin.Instance!.FingerprintCachePath,
id.ToString("N"));
// File.Delete(cachePath);
// File.Delete(cachePath + "-intro-silence-v1");
// File.Delete(cachePath + "-credits");
var filePattern = Path.GetFileName(cachePath) + "*";
foreach (var filePath in Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath, filePattern))
{
File.Delete(filePath);
}
}
/// <summary>
/// Remove cached fingerprints from disk by mode.
/// </summary>
/// <param name="mode">Analysis mode.</param>
public static void DeleteCacheFiles(AnalysisMode mode)
{
foreach (var filePath in Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath))
{
var shouldDelete = (mode == AnalysisMode.Introduction)
? !filePath.Contains("credit", StringComparison.OrdinalIgnoreCase)
&& !filePath.Contains("blackframes", StringComparison.OrdinalIgnoreCase)
: filePath.Contains("credit", StringComparison.OrdinalIgnoreCase)
|| filePath.Contains("blackframes", StringComparison.OrdinalIgnoreCase);
if (shouldDelete)
{
File.Delete(filePath);
}
}
}
/// <summary>
/// Remove a cached episode fingerprint from disk.
/// </summary>
public static void CleanCacheFiles()
{
// Get valid episode IDs from the dictionaries
HashSet<Guid> validEpisodeIds = new HashSet<Guid>(Plugin.Instance!.Intros.Keys.Concat(Plugin.Instance!.Credits.Keys)); // Or use GetMediaItems instead?
// Delete invalid cache files
foreach (string filePath in Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath))
{
string fileName = Path.GetFileNameWithoutExtension(filePath);
int dashIndex = fileName.IndexOf('-', StringComparison.Ordinal); // Find the index of the first '-' character
if (dashIndex > 0)
{
fileName = fileName.Substring(0, dashIndex);
}
if (Guid.TryParse(fileName, out Guid episodeId))
{
if (!validEpisodeIds.Contains(episodeId))
{
DeleteEpisodeCache(episodeId);
}
}
}
}
/// <summary>
/// Determines the path an episode should be cached at.
/// This function was created before the unified caching mechanism was introduced (in v0.1.7).
/// </summary>
/// <param name="episode">Episode.</param>
/// <param name="mode">Analysis mode.</param>
private static string GetFingerprintCachePath(QueuedEpisode episode, AnalysisMode mode)
{
var basePath = Path.Join(
Plugin.Instance!.FingerprintCachePath,
episode.EpisodeId.ToString("N"));
if (mode == AnalysisMode.Introduction)
{
return basePath;
}
if (mode == AnalysisMode.Credits)
{
return basePath + "-credits";
}
throw new ArgumentException("Unknown analysis mode " + mode);
}
private static string FormatFFmpegLog(string key)
{
/* Format:
* FFmpeg NAME:
* ```
* LOGS
* ```
*/
var formatted = string.Format(CultureInfo.InvariantCulture, "FFmpeg {0}:\n```\n", key);
formatted += ChromaprintLogs[key];
// Ensure the closing triple backtick is on a separate line
if (!formatted.EndsWith('\n'))
{
formatted += "\n";
}
formatted += "```\n\n";
return formatted;
}
}

View File

@ -1,10 +0,0 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.WarningManager")]
[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.IntroWithMetadata")]
[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.TimeRangeHelpers")]

View File

@ -1,408 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Intro skipper plugin. Uses audio analysis to find common sequences of audio shared between episodes.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
private readonly object _serializationLock = new();
private readonly object _introsLock = new();
private ILibraryManager _libraryManager;
private IItemRepository _itemRepository;
private ILogger<Plugin> _logger;
private string _introPath;
private string _creditsPath;
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
/// <param name="serverConfiguration">Server configuration manager.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="itemRepository">Item repository.</param>
/// <param name="logger">Logger.</param>
public Plugin(
IApplicationPaths applicationPaths,
IXmlSerializer xmlSerializer,
IServerConfigurationManager serverConfiguration,
ILibraryManager libraryManager,
IItemRepository itemRepository,
ILogger<Plugin> logger)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
_libraryManager = libraryManager;
_itemRepository = itemRepository;
_logger = logger;
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
ArgumentNullException.ThrowIfNull(applicationPaths);
var pluginDirName = "introskipper";
var pluginCachePath = "chromaprints";
var introsDirectory = Path.Join(applicationPaths.DataPath, pluginDirName);
FingerprintCachePath = Path.Join(introsDirectory, pluginCachePath);
_introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml");
_creditsPath = Path.Join(applicationPaths.DataPath, pluginDirName, "credits.xml");
var cacheRoot = applicationPaths.CachePath;
var oldIntrosDirectory = Path.Join(cacheRoot, pluginDirName);
if (!Directory.Exists(oldIntrosDirectory))
{
pluginDirName = "intros";
pluginCachePath = "cache";
cacheRoot = applicationPaths.PluginConfigurationsPath;
oldIntrosDirectory = Path.Join(cacheRoot, pluginDirName);
}
var oldFingerprintCachePath = Path.Join(oldIntrosDirectory, pluginCachePath);
var oldIntroPath = Path.Join(cacheRoot, pluginDirName, "intros.xml");
var oldCreditsPath = Path.Join(cacheRoot, pluginDirName, "credits.xml");
// Create the base & cache directories (if needed).
if (!Directory.Exists(FingerprintCachePath))
{
Directory.CreateDirectory(FingerprintCachePath);
// Check if the old cache directory exists
if (Directory.Exists(oldFingerprintCachePath))
{
// move intro.xml if exists
if (File.Exists(oldIntroPath))
{
File.Move(oldIntroPath, _introPath);
}
// move credits.xml if exists
if (File.Exists(oldCreditsPath))
{
File.Move(oldCreditsPath, _creditsPath);
}
// Move the contents from old directory to new directory
string[] files = Directory.GetFiles(oldFingerprintCachePath);
foreach (string file in files)
{
string fileName = Path.GetFileName(file);
string destFile = Path.Combine(FingerprintCachePath, fileName);
File.Move(file, destFile);
}
// Optionally, you may delete the old directory after moving its contents
Directory.Delete(oldIntrosDirectory, true);
}
}
// migrate from XMLSchema to DataContract
XmlSerializationHelper.MigrateXML(_introPath);
XmlSerializationHelper.MigrateXML(_creditsPath);
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
try
{
RestoreTimestamps();
}
catch (Exception ex)
{
_logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex);
}
// Inject the skip intro button code into the web interface.
var indexPath = Path.Join(applicationPaths.WebPath, "index.html");
try
{
InjectSkipButton(indexPath);
}
catch (Exception ex)
{
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
if (ex is UnauthorizedAccessException)
{
var suggestion = OperatingSystem.IsLinux() ?
"running `sudo chown jellyfin PATH` (if this is a native installation)" :
"changing the permissions of PATH";
suggestion = suggestion.Replace("PATH", indexPath, StringComparison.Ordinal);
_logger.LogError(
"Failed to add skip button to web interface. Try {Suggestion} and restarting the server. Error: {Error}",
suggestion,
ex);
}
else
{
_logger.LogError("Unknown error encountered while adding skip button: {Error}", ex);
}
}
FFmpegWrapper.CheckFFmpegVersion();
}
/// <summary>
/// Gets the results of fingerprinting all episodes.
/// </summary>
public ConcurrentDictionary<Guid, Intro> Intros { get; } = new();
/// <summary>
/// Gets all discovered ending credits.
/// </summary>
public ConcurrentDictionary<Guid, Intro> Credits { get; } = new();
/// <summary>
/// Gets the most recent media item queue.
/// </summary>
public ConcurrentDictionary<Guid, List<QueuedEpisode>> QueuedMediaItems { get; } = new();
/// <summary>
/// Gets or sets the total number of episodes in the queue.
/// </summary>
public int TotalQueued { get; set; }
/// <summary>
/// Gets or sets the number of seasons in the queue.
/// </summary>
public int TotalSeasons { get; set; }
/// <summary>
/// Gets the directory to cache fingerprints in.
/// </summary>
public string FingerprintCachePath { get; private set; }
/// <summary>
/// Gets the full path to FFmpeg.
/// </summary>
public string FFmpegPath { get; private set; }
/// <inheritdoc />
public override string Name => "Intro Skipper";
/// <inheritdoc />
public override Guid Id => Guid.Parse("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b");
/// <summary>
/// Gets the plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <summary>
/// Save timestamps to disk.
/// </summary>
/// <param name="mode">Mode.</param>
public void SaveTimestamps(AnalysisMode mode)
{
List<Intro> introList = new List<Intro>();
var filePath = mode == AnalysisMode.Introduction
? _introPath
: _creditsPath;
lock (_introsLock)
{
introList.AddRange(mode == AnalysisMode.Introduction
? Instance!.Intros.Values
: Instance!.Credits.Values);
}
lock (_serializationLock)
{
try
{
XmlSerializationHelper.SerializeToXml(introList, filePath);
}
catch (Exception e)
{
_logger.LogError("SaveTimestamps {Message}", e.Message);
}
}
}
/// <summary>
/// Restore previous analysis results from disk.
/// </summary>
public void RestoreTimestamps()
{
if (File.Exists(_introPath))
{
// Since dictionaries can't be easily serialized, analysis results are stored on disk as a list.
var introList = XmlSerializationHelper.DeserializeFromXml(_introPath);
foreach (var intro in introList)
{
Instance!.Intros.TryAdd(intro.EpisodeId, intro);
}
}
if (File.Exists(_creditsPath))
{
var creditList = XmlSerializationHelper.DeserializeFromXml(_creditsPath);
foreach (var credit in creditList)
{
Instance!.Credits.TryAdd(credit.EpisodeId, credit);
}
}
}
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = this.Name,
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
},
new PluginPageInfo
{
Name = "visualizer.js",
EmbeddedResourcePath = GetType().Namespace + ".Configuration.visualizer.js"
},
new PluginPageInfo
{
Name = "skip-intro-button.js",
EmbeddedResourcePath = GetType().Namespace + ".Configuration.inject.js"
}
};
}
/// <summary>
/// Gets the commit used to build the plugin.
/// </summary>
/// <returns>Commit.</returns>
public string GetCommit()
{
var commit = string.Empty;
var path = GetType().Namespace + ".Configuration.version.txt";
using var stream = GetType().Assembly.GetManifestResourceStream(path);
if (stream is null)
{
_logger.LogWarning("Unable to read embedded version information");
return commit;
}
using var reader = new StreamReader(stream);
commit = reader.ReadToEnd().TrimEnd();
if (commit == "unknown")
{
_logger.LogTrace("Embedded version information was not valid, ignoring");
return string.Empty;
}
_logger.LogInformation("Unstable plugin version built from commit {Commit}", commit);
return commit;
}
internal BaseItem? GetItem(Guid id)
{
return _libraryManager.GetItemById(id);
}
/// <summary>
/// Gets the full path for an item.
/// </summary>
/// <param name="id">Item id.</param>
/// <returns>Full path to item.</returns>
internal string GetItemPath(Guid id)
{
var item = GetItem(id);
if (item == null)
{
// Handle the case where the item is not found
_logger.LogWarning("Item with ID {Id} not found.", id);
return string.Empty;
}
return item.Path;
}
/// <summary>
/// Gets all chapters for this item.
/// </summary>
/// <param name="id">Item id.</param>
/// <returns>List of chapters.</returns>
internal List<ChapterInfo> GetChapters(Guid id)
{
var item = GetItem(id);
if (item == null)
{
// Handle the case where the item is not found
_logger.LogWarning("Item with ID {Id} not found.", id);
return new List<ChapterInfo>();
}
return _itemRepository.GetChapters(item);
}
internal void UpdateTimestamps(Dictionary<Guid, Intro> newTimestamps, AnalysisMode mode)
{
foreach (var intro in newTimestamps)
{
if (mode == AnalysisMode.Introduction)
{
Instance!.Intros.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value);
}
else if (mode == AnalysisMode.Credits)
{
Instance!.Credits.AddOrUpdate(intro.Key, intro.Value, (key, oldValue) => intro.Value);
}
}
SaveTimestamps(mode);
}
/// <summary>
/// Inject the skip button script into the web interface.
/// </summary>
/// <param name="indexPath">Full path to index.html.</param>
private void InjectSkipButton(string indexPath)
{
// Parts of this code are based off of JellyScrub's script injection code.
// https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/JellyscrubPlugin.cs#L38
_logger.LogDebug("Reading index.html from {Path}", indexPath);
var contents = File.ReadAllText(indexPath);
var scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js\"></script>";
// Only inject the script tag once
if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Skip button already added");
return;
}
// Inject a link to the script at the end of the <head> section.
// A regex is used here to ensure the replacement is only done once.
var headEnd = new Regex("</head>", RegexOptions.IgnoreCase);
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
// Write the modified file contents
File.WriteAllText(indexPath, contents);
_logger.LogInformation("Skip intro button successfully added");
}
}

View File

@ -1,20 +0,0 @@
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace ConfusedPolarBear.Plugin.IntroSkipper
{
/// <summary>
/// Register Intro Skipper services.
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddHostedService<AutoSkip>();
serviceCollection.AddHostedService<AutoSkipCredits>();
serviceCollection.AddHostedService<Entrypoint>();
}
}
}

View File

@ -1,290 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Manages enqueuing library items for analysis.
/// </summary>
public class QueueManager
{
private ILibraryManager _libraryManager;
private ILogger<QueueManager> _logger;
private double analysisPercent;
private List<string> selectedLibraries;
private Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes;
/// <summary>
/// Initializes a new instance of the <see cref="QueueManager"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
public QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryManager)
{
_logger = logger;
_libraryManager = libraryManager;
selectedLibraries = new();
_queuedEpisodes = new();
}
/// <summary>
/// Gets all media items on the server.
/// </summary>
/// <returns>Queued media items.</returns>
public ReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems()
{
Plugin.Instance!.TotalQueued = 0;
LoadAnalysisSettings();
// For all selected libraries, enqueue all contained episodes.
foreach (var folder in _libraryManager.GetVirtualFolders())
{
// If libraries have been selected for analysis, ensure this library was selected.
if (selectedLibraries.Count > 0 && !selectedLibraries.Contains(folder.Name))
{
_logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name);
continue;
}
_logger.LogInformation("Running enqueue of items in library {Name}", folder.Name);
try
{
foreach (var location in folder.Locations)
{
var item = _libraryManager.FindByPath(location, true);
if (item is null)
{
_logger.LogWarning("Unable to find linked item at path {0}", location);
continue;
}
QueueLibraryContents(item.Id);
}
}
catch (Exception ex)
{
_logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex);
}
}
Plugin.Instance.TotalSeasons = _queuedEpisodes.Count;
Plugin.Instance.QueuedMediaItems.Clear();
foreach (var kvp in _queuedEpisodes)
{
Plugin.Instance.QueuedMediaItems.TryAdd(kvp.Key, kvp.Value);
}
return new(_queuedEpisodes);
}
/// <summary>
/// Loads the list of libraries which have been selected for analysis and the minimum intro duration.
/// Settings which have been modified from the defaults are logged.
/// </summary>
private void LoadAnalysisSettings()
{
var config = Plugin.Instance!.Configuration;
// Store the analysis percent
analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100;
// Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries.
selectedLibraries = config.SelectedLibraries
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
// If any libraries have been selected for analysis, log their names.
if (selectedLibraries.Count > 0)
{
_logger.LogInformation("Limiting analysis to the following libraries: {Selected}", selectedLibraries);
}
else
{
_logger.LogDebug("Not limiting analysis by library name");
}
// If analysis settings have been changed from the default, log the modified settings.
if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15)
{
_logger.LogInformation(
"Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s",
config.AnalysisPercent,
config.AnalysisLengthLimit,
config.MinimumIntroDuration);
}
}
private void QueueLibraryContents(Guid id)
{
_logger.LogDebug("Constructing anonymous internal query");
var query = new InternalItemsQuery
{
// Order by series name, season, and then episode number so that status updates are logged in order
ParentId = id,
OrderBy = new[] { (ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending), },
IncludeItemTypes = [BaseItemKind.Episode],
Recursive = true,
IsVirtualItem = false
};
var items = _libraryManager.GetItemList(query, false);
if (items is null)
{
_logger.LogError("Library query result is null");
return;
}
// Queue all episodes on the server for fingerprinting.
_logger.LogDebug("Iterating through library items");
foreach (var item in items)
{
if (item is not Episode episode)
{
_logger.LogDebug("Item {Name} is not an episode", item.Name);
continue;
}
if (Plugin.Instance!.Configuration.PathRestrictions.Count > 0)
{
if (!Plugin.Instance.Configuration.PathRestrictions.Contains(item.ContainingFolderPath))
{
continue;
}
}
QueueEpisode(episode);
}
_logger.LogDebug("Queued {Count} episodes", items.Count);
}
private void QueueEpisode(Episode episode)
{
if (Plugin.Instance is null)
{
throw new InvalidOperationException("plugin instance was null");
}
if (string.IsNullOrEmpty(episode.Path))
{
_logger.LogWarning(
"Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin",
episode.Name,
episode.SeriesName,
episode.Id);
return;
}
// Allocate a new list for each new season
_queuedEpisodes.TryAdd(episode.SeasonId, new List<QueuedEpisode>());
if (_queuedEpisodes[episode.SeasonId].Any(e => e.EpisodeId == episode.Id))
{
_logger.LogDebug(
"\"{Name}\" from series \"{Series}\" ({Id}) is already queued",
episode.Name,
episode.SeriesName,
episode.Id);
return;
}
// Limit analysis to the first X% of the episode and at most Y minutes.
// X and Y default to 25% and 10 minutes.
var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
var fingerprintDuration = duration;
if (fingerprintDuration >= 5 * 60)
{
fingerprintDuration *= analysisPercent;
}
fingerprintDuration = Math.Min(
fingerprintDuration,
60 * Plugin.Instance.Configuration.AnalysisLengthLimit);
// Queue the episode for analysis
var maxCreditsDuration = Plugin.Instance.Configuration.MaximumCreditsDuration;
_queuedEpisodes[episode.SeasonId].Add(new QueuedEpisode
{
SeriesName = episode.SeriesName,
SeasonNumber = episode.AiredSeasonNumber ?? 0,
EpisodeId = episode.Id,
Name = episode.Name,
Path = episode.Path,
Duration = Convert.ToInt32(duration),
IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration),
CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration),
});
Plugin.Instance.TotalQueued++;
}
/// <summary>
/// Verify that a collection of queued media items still exist in Jellyfin and in storage.
/// This is done to ensure that we don't analyze items that were deleted between the call to GetMediaItems() and popping them from the queue.
/// </summary>
/// <param name="candidates">Queued media items.</param>
/// <param name="modes">Analysis mode.</param>
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
public (ReadOnlyCollection<QueuedEpisode> VerifiedItems, ReadOnlyCollection<AnalysisMode> RequiredModes)
VerifyQueue(ReadOnlyCollection<QueuedEpisode> candidates, ReadOnlyCollection<AnalysisMode> modes)
{
var verified = new List<QueuedEpisode>();
var reqModes = new List<AnalysisMode>();
var requiresIntroAnalysis = modes.Contains(AnalysisMode.Introduction);
var requiresCreditsAnalysis = modes.Contains(AnalysisMode.Credits);
foreach (var candidate in candidates)
{
try
{
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
if (File.Exists(path))
{
verified.Add(candidate);
if (requiresIntroAnalysis && (!Plugin.Instance!.Intros.TryGetValue(candidate.EpisodeId, out var intro) || !intro.Valid))
{
reqModes.Add(AnalysisMode.Introduction);
requiresIntroAnalysis = false; // No need to check again
}
if (requiresCreditsAnalysis && (!Plugin.Instance!.Credits.TryGetValue(candidate.EpisodeId, out var credit) || !credit.Valid))
{
reqModes.Add(AnalysisMode.Credits);
requiresCreditsAnalysis = false; // No need to check again
}
}
}
catch (Exception ex)
{
_logger.LogDebug(
"Skipping {Mode} analysis of {Name} ({Id}): {Exception}",
modes,
candidate.Name,
candidate.EpisodeId,
ex);
}
}
return (verified.AsReadOnly(), reqModes.AsReadOnly());
}
}

View File

@ -1,226 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Common code shared by all media item analyzer tasks.
/// </summary>
public class BaseItemAnalyzerTask
{
private readonly ReadOnlyCollection<AnalysisMode> _analysisModes;
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="BaseItemAnalyzerTask"/> class.
/// </summary>
/// <param name="modes">Analysis mode.</param>
/// <param name="logger">Task logger.</param>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
public BaseItemAnalyzerTask(
ReadOnlyCollection<AnalysisMode> modes,
ILogger logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_analysisModes = modes;
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
{
EdlManager.Initialize(_logger);
}
}
/// <summary>
/// Analyze all media items on the server.
/// </summary>
/// <param name="progress">Progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public void AnalyzeItems(
IProgress<double> progress,
CancellationToken cancellationToken)
{
var ffmpegValid = FFmpegWrapper.CheckFFmpegVersion();
// Assert that ffmpeg with chromaprint is installed
if (Plugin.Instance!.Configuration.UseChromaprint && !ffmpegValid)
{
throw new FingerprintException(
"Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg5. If Jellyfin is running in a container, upgrade to version 10.8.0 or newer.");
}
var queueManager = new QueueManager(
_loggerFactory.CreateLogger<QueueManager>(),
_libraryManager);
var queue = queueManager.GetMediaItems();
var totalQueued = 0;
foreach (var kvp in queue)
{
totalQueued += kvp.Value.Count;
}
totalQueued *= _analysisModes.Count;
if (totalQueued == 0)
{
throw new FingerprintException(
"No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly.");
}
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
{
EdlManager.LogConfiguration();
}
var totalProcessed = 0;
var modeCount = _analysisModes.Count;
var options = new ParallelOptions
{
MaxDegreeOfParallelism = Plugin.Instance.Configuration.MaxParallelism
};
Parallel.ForEach(queue, options, season =>
{
var writeEdl = false;
// Since the first run of the task can run for multiple hours, ensure that none
// of the current media items were deleted from Jellyfin since the task was started.
var (episodes, requiredModes) = queueManager.VerifyQueue(
season.Value.AsReadOnly(),
_analysisModes);
var episodeCount = episodes.Count;
if (episodeCount == 0)
{
return;
}
var first = episodes[0];
var requiredModeCount = requiredModes.Count;
if (requiredModeCount == 0)
{
_logger.LogDebug(
"All episodes in {Name} season {Season} have already been analyzed",
first.SeriesName,
first.SeasonNumber);
Interlocked.Add(ref totalProcessed, episodeCount * modeCount); // Update total Processed directly
progress.Report((totalProcessed * 100) / totalQueued);
return;
}
if (modeCount != requiredModeCount)
{
Interlocked.Add(ref totalProcessed, episodeCount);
progress.Report((totalProcessed * 100) / totalQueued); // Partial analysis some modes have already been analyzed
}
try
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
foreach (AnalysisMode mode in requiredModes)
{
var analyzed = AnalyzeItems(episodes, mode, cancellationToken);
Interlocked.Add(ref totalProcessed, analyzed);
writeEdl = analyzed > 0 || Plugin.Instance.Configuration.RegenerateEdlFiles;
progress.Report((totalProcessed * 100) / totalQueued);
}
}
catch (FingerprintException ex)
{
_logger.LogWarning(
"Unable to analyze {Series} season {Season}: unable to fingerprint: {Ex}",
first.SeriesName,
first.SeasonNumber,
ex);
}
if (writeEdl && Plugin.Instance.Configuration.EdlAction != EdlAction.None)
{
EdlManager.UpdateEDLFiles(episodes);
}
});
if (Plugin.Instance.Configuration.RegenerateEdlFiles)
{
_logger.LogInformation("Turning EDL file regeneration flag off");
Plugin.Instance.Configuration.RegenerateEdlFiles = false;
Plugin.Instance.SaveConfiguration();
}
}
/// <summary>
/// Analyze a group of media items for skippable segments.
/// </summary>
/// <param name="items">Media items to analyze.</param>
/// <param name="mode">Analysis mode.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of items that were successfully analyzed.</returns>
private int AnalyzeItems(
ReadOnlyCollection<QueuedEpisode> items,
AnalysisMode mode,
CancellationToken cancellationToken)
{
var totalItems = items.Count;
// Only analyze specials (season 0) if the user has opted in.
var first = items[0];
if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
{
return 0;
}
_logger.LogInformation(
"[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}",
mode,
items.Count,
first.SeriesName,
first.SeasonNumber);
var analyzers = new Collection<IMediaFileAnalyzer>();
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
if (mode == AnalysisMode.Credits)
{
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
}
if (Plugin.Instance!.Configuration.UseChromaprint)
{
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
}
// Use each analyzer to find skippable ranges in all media files, removing successfully
// analyzed items from the queue.
foreach (var analyzer in analyzers)
{
items = analyzer.AnalyzeMediaFiles(items, mode, cancellationToken);
}
return totalItems;
}
}

View File

@ -1,107 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Analyze all television episodes for credits.
/// TODO: analyze all media files.
/// </summary>
public class DetectCreditsTask : IScheduledTask
{
private readonly ILogger<DetectCreditsTask> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectCreditsTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public DetectCreditsTask(
ILogger<DetectCreditsTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Credits";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes media to determine the timestamp and length of credits";
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectCredits";
/// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
/// </summary>
/// <param name="progress">Task progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task.</returns>
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
if (_libraryManager is null)
{
throw new InvalidOperationException("Library manager was null");
}
// abort automatic analyzer if running
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
{
_logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
Entrypoint.CancelAutomaticTask(cancellationToken);
}
using (ScheduledTaskSemaphore.Acquire(-1, cancellationToken))
{
_logger.LogInformation("Scheduled Task is starting");
Plugin.Instance!.Configuration.PathRestrictions.Clear();
var modes = new List<AnalysisMode> { AnalysisMode.Credits };
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
modes.AsReadOnly(),
_loggerFactory.CreateLogger<DetectCreditsTask>(),
_loggerFactory,
_libraryManager);
baseCreditAnalyzer.AnalyzeItems(progress, cancellationToken);
return Task.CompletedTask;
}
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return Array.Empty<TaskTriggerInfo>();
}
}

View File

@ -1,113 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Analyze all television episodes for introduction sequences.
/// </summary>
public class DetectIntrosCreditsTask : IScheduledTask
{
private readonly ILogger<DetectIntrosCreditsTask> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectIntrosCreditsTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public DetectIntrosCreditsTask(
ILogger<DetectIntrosCreditsTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Intros and Credits";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes media to determine the timestamp and length of intros and credits.";
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectIntrosCredits";
/// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
/// </summary>
/// <param name="progress">Task progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task.</returns>
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
if (_libraryManager is null)
{
throw new InvalidOperationException("Library manager was null");
}
// abort automatic analyzer if running
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
{
_logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
Entrypoint.CancelAutomaticTask(cancellationToken);
}
using (ScheduledTaskSemaphore.Acquire(-1, cancellationToken))
{
_logger.LogInformation("Scheduled Task is starting");
Plugin.Instance!.Configuration.PathRestrictions.Clear();
var modes = new List<AnalysisMode> { AnalysisMode.Introduction, AnalysisMode.Credits };
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
modes.AsReadOnly(),
_loggerFactory.CreateLogger<DetectIntrosCreditsTask>(),
_loggerFactory,
_libraryManager);
baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
return Task.CompletedTask;
}
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromHours(0).Ticks
}
};
}
}

View File

@ -1,106 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Analyze all television episodes for introduction sequences.
/// </summary>
public class DetectIntrosTask : IScheduledTask
{
private readonly ILogger<DetectIntrosTask> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectIntrosTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public DetectIntrosTask(
ILogger<DetectIntrosTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Intros";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes media to determine the timestamp and length of intros.";
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectIntroductions";
/// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
/// </summary>
/// <param name="progress">Task progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task.</returns>
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
if (_libraryManager is null)
{
throw new InvalidOperationException("Library manager was null");
}
// abort automatic analyzer if running
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
{
_logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
Entrypoint.CancelAutomaticTask(cancellationToken);
}
using (ScheduledTaskSemaphore.Acquire(-1, cancellationToken))
{
_logger.LogInformation("Scheduled Task is starting");
Plugin.Instance!.Configuration.PathRestrictions.Clear();
var modes = new List<AnalysisMode> { AnalysisMode.Introduction };
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
modes.AsReadOnly(),
_loggerFactory.CreateLogger<DetectIntrosTask>(),
_loggerFactory,
_libraryManager);
baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
return Task.CompletedTask;
}
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return Array.Empty<TaskTriggerInfo>();
}
}

View File

@ -1,35 +0,0 @@
using System;
using System.Threading;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
internal sealed class ScheduledTaskSemaphore : IDisposable
{
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
private static bool _isHeld = false;
private ScheduledTaskSemaphore()
{
}
public static int CurrentCount => _semaphore.CurrentCount;
public static IDisposable Acquire(int timeout, CancellationToken cancellationToken)
{
_isHeld = _semaphore.Wait(timeout, cancellationToken);
return new ScheduledTaskSemaphore();
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
if (_isHeld) // Release only if acquired
{
_semaphore.Release();
_isHeld = false;
}
}
}

View File

@ -1,85 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization;
using System.Xml;
namespace ConfusedPolarBear.Plugin.IntroSkipper
{
internal sealed class XmlSerializationHelper
{
public static void SerializeToXml<T>(T obj, string filePath)
{
// Create a FileStream to write the XML file
using FileStream fileStream = new FileStream(filePath, FileMode.Create);
// Create a DataContractSerializer for type T
DataContractSerializer serializer = new DataContractSerializer(typeof(T));
// Serialize the object to the FileStream
serializer.WriteObject(fileStream, obj);
}
public static List<Intro> DeserializeFromXml(string filePath)
{
var result = new List<Intro>();
try
{
// Create a FileStream to read the XML file
using FileStream fileStream = new FileStream(filePath, FileMode.Open);
// Create an XmlDictionaryReader to read the XML
XmlDictionaryReader reader = XmlDictionaryReader.CreateTextReader(fileStream, new XmlDictionaryReaderQuotas());
// Create a DataContractSerializer for type T
DataContractSerializer serializer = new DataContractSerializer(typeof(List<Intro>));
// Deserialize the object from the XML
result = serializer.ReadObject(reader) as List<Intro>;
// Close the reader
reader.Close();
}
catch (Exception ex)
{
Console.WriteLine($"Error deserializing XML: {ex.Message}");
}
ArgumentNullException.ThrowIfNull(result);
// Return the deserialized object
return result;
}
public static void MigrateXML(string filePath)
{
if (File.Exists(filePath))
{
try
{
// Load the XML document
XmlDocument xmlDoc = new XmlDocument();
xmlDoc.Load(filePath);
ArgumentNullException.ThrowIfNull(xmlDoc.DocumentElement);
// Check that the file has not already been migrated
if (xmlDoc.DocumentElement.HasAttribute("xmlns:xsi"))
{
xmlDoc.DocumentElement.RemoveAttribute("xmlns:xsi");
xmlDoc.DocumentElement.RemoveAttribute("xmlns:xsd");
xmlDoc.DocumentElement.SetAttribute("xmlns", "http://schemas.datacontract.org/2004/07/ConfusedPolarBear.Plugin.IntroSkipper");
xmlDoc.DocumentElement.SetAttribute("xmlns:i", "http://www.w3.org/2001/XMLSchema-instance");
// Save the modified XML document
xmlDoc.Save(filePath);
}
}
catch (XmlException ex)
{
Console.WriteLine($"Error deserializing XML: {ex.Message}");
File.Delete(filePath);
Console.WriteLine($"Deleting {filePath}");
}
}
}
}
}

4
Dockerfile Normal file
View File

@ -0,0 +1,4 @@
FROM scratch
# copy local files
COPY root/ /

View File

@ -1,93 +1,7 @@
# Intro Skipper (beta)
# Opencl-Intel - Docker mod for Jellyfin
<div align="center">
<p>
<img alt="Plugin Banner" src="https://raw.githubusercontent.com/jumoog/intro-skipper/master/images/logo.png" />
</p>
<p>
Analyzes the audio of television episodes to detect and skip over intros.
</p>
[![CodeQL](https://github.com/jumoog/intro-skipper/actions/workflows/codeql.yml/badge.svg)](https://github.com/jumoog/intro-skipper/actions/workflows/codeql.yml)
</div>
This mod adds skip button to jellyfin, to be installed/updated during container start.
## Jellyfin 10.8
👉👉👉 [Jellyfin 10.8 Instructions](https://github.com/jumoog/intro-skipper/blob/10.8/README.md)
In jellyfin docker arguments, set an environment variable `DOCKER_MODS=ghcr.io/jumoog/intro-skipper`
## System requirements
* Jellyfin 10.9.3 (or newer)
* Jellyfin's [fork](https://github.com/jellyfin/jellyfin-ffmpeg) of `ffmpeg` must be installed, version `6.0.1-5` or newer
* `jellyfin/jellyfin` 10.9.z container: preinstalled
* `linuxserver/jellyfin` 10.9.z container: preinstalled
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg6` package
* MacOS native installs: build ffmpeg with chromaprint support ([instructions](#installation-instructions-for-macos))
## 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
## Installation
### Step 1: Install the plugin
1. Add this plugin repository to your server: `https://raw.githubusercontent.com/jumoog/intro-skipper/master/manifest.json`
2. Install the Intro Skipper plugin from the General section
3. Restart Jellyfin
### Step 2: Configure the plugin
4. OPTIONAL: Enable automatic skipping or skip button
1. Go to Dashboard -> Plugins -> Intro Skipper
2. Check "Automatically skip intros" or "Show skip intro button" and click Save
5. Go to Dashboard -> Scheduled Tasks -> Analyze Episodes and click the play button
6. After a season has completed analyzing, play some episodes from it and observe the results
1. Status updates are logged before analyzing each season of a show
## Troubleshooting
#### Scheduled tasks fail instantly
- Verify that Intro Skipper can detect ffmpeg with Chromaprint
- Dashboard -> Plugins -> Intro Skipper -> Support Bundle Info
- Verify that ffmpeg is installed and detected by jellyfin
- Dashboard -> Playback -> FFmpeg path
- Verify that Chromaprint is enabled in ffmpeg (`--enable-chromaprint`)
#### Skip button is not visible
- Verify you have successfully completed the scheduled task at least once
- Clear your browser cache and reload the Jellyfin server webpage
- Fix any permission mismatches between the web folder and Jellyfin server
* <b>Docker -</b> the container is being run as a non-root user while having been built as a root user, causing the web files to be owned by root. To solve this, you can remove any lines like `User: 1000:1000`, `GUID:`, `PID:`, etc. from the jellyfin docker compose file.
* <b>Install from distro repositories -</b> the jellyfin-server will execute as `jellyfin` user while the web files will be owned by `root`, `www-data`, etc. This can <i>likely</i> be fixed by adding the `jellyfin` user (or whichever user executes the jellyfin server) to the same group that owns the jellyfin-web folders. **You should only do this if they are owned by a group other than root**.
- The official Android TV app do not support the skip button. For this app, you will need to use the autoskip option. Please note that there is currently an [issue](https://github.com/jumoog/intro-skipper/issues/168) with autoskip not working because the apps never receive the seek command from Jellyfin.
## Installation (MacOS)
1. Build ffmpeg with chromaprint support using brew:
- macOS 12 or newer can install the [portable jellyfin-ffmpeg](https://github.com/jellyfin/jellyfin-ffmpeg)
```
brew uninstall --force --ignore-dependencies ffmpeg
brew install chromaprint amiaopensource/amiaos/decklinksdk
brew tap homebrew-ffmpeg/ffmpeg
brew install homebrew-ffmpeg/ffmpeg/ffmpeg --with-chromaprint
brew link --overwrite ffmpeg
```
2. Open ~/.config/jellyfin/encoding.xml and add or edit the following lines
- Replace [FFMPEG_PATH] with the path returned by `whereis ffmpeg`
```
<EncoderAppPath>[FFMPEG_PATH]</EncoderAppPath>
<EncoderAppPathDisplay>[FFMPEG_PATH]</EncoderAppPathDisplay>
```
4. Follow the [general installation instructions](#installation) above
## Documentation
Documentation about how the API works can be found in [api.md](docs/api.md).
If adding multiple mods, enter them in an array separated by `|`, such as `DOCKER_MODS=ghcr.io/jumoog/intro-skipper|linuxserver/mods:jellyfin-mod2`

View File

@ -1,54 +0,0 @@
# API
## General
The main API endpoint exposed by this plugin is `/Episode/{ItemId}/IntroTimestamps`. If an introduction was detected inside of a television episode, this endpoint will return the timestamps of that intro.
An API version can be optionally selected by appending `/v{Version}` to the URL. If a version is not specified, version 1 will be selected.
## API version 1 (default)
API version 1 was introduced with the initial alpha release of the plugin. It is accessible (via a `GET` request) on the following URLs:
* `/Episode/{ItemId}/IntroTimestamps`
* `/Episode/{ItemId}/IntroTimestamps/v1`
Both of these endpoints require an authorization token to be provided.
The possible status codes of this endpoint are:
* `200 (OK)`: An introduction was detected for this item and the response is deserializable as JSON using the schema below.
* `404 (Not Found)`: Either no introduction was detected for this item or it is not a television episode.
JSON schema:
```jsonc
{
"EpisodeId": "{item id}", // Unique GUID for this item as provided by Jellyfin.
"Valid": true, // Used internally to mark items that have intros. Should be ignored as it will always be true.
"IntroStart": 100.5, // Start time (in seconds) of the introduction.
"IntroEnd": 130.42, // End time (in seconds) of the introduction.
"ShowSkipPromptAt": 95.5, // Recommended time to display an on-screen intro skip prompt to the user.
"HideSkipPromptAt": 110.5 // Recommended time to hide the on-screen intro skip prompt.
}
```
The `ShowSkipPromptAt` and `HideSkipPromptAt` properties are derived from the start time of the introduction and are customizable by the user from the plugin's settings.
### Example curl command
`curl` command to get introduction timestamps for the item with id `12345678901234567890123456789012`:
```shell
curl http://127.0.0.1:8096/Episode/12345678901234567890123456789012/IntroTimestamps/v1 -H 'Authorization: MediaBrowser Token="98765432109876543210987654321098"'
```
This returns the following JSON object:
```json
{
"EpisodeId": "12345678901234567890123456789012",
"Valid": true,
"IntroStart": 304,
"IntroEnd": 397.48,
"ShowSkipPromptAt": 299,
"HideSkipPromptAt": 314
}
```

View File

@ -1,44 +0,0 @@
# How to enable plugin debug logs
1. Browse to your Jellyfin config folder
2. Make a backup copy of `config/logging.default.json` before editing it
3. Open `config/logging.default.json` with a text editor. The top lines should look something like this:
```jsonc
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
// rest of file ommited for brevity
}
}
```
4. Inside the `Override` section, add a new entry for `ConfusedPolarBear` and set it to `Debug`. The modified file should now look like this:
```jsonc
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning", // be sure to add the trailing comma after "Warning",
"ConfusedPolarBear": "Debug" // newly added line
}
},
// rest of file ommited for brevity
}
}
```
5. Save the file and restart Jellyfin
## How to enable verbose logs
To enable verbose log messages, set the log level to `Verbose` instead of `Debug` in step 4.

View File

@ -1,16 +0,0 @@
# EDL support
The timestamps of discovered introductions can be written to [EDL](https://kodi.wiki/view/Edit_decision_list) files alongside your media files. EDL files are saved when:
* Scanning an episode for the first time, or
* If requested with the regenerate checkbox
## Configuration
Jellyfin must have read/write access to your TV show libraries in order to make use of this feature.
## Usage
To have the plugin create EDL files:
1. Change the EDL action from the default of None to any of the other supported EDL actions
2. Check the "Regenerate EDL files during next analysis" checkbox
1. If this option is not selected, only seasons with a newly analyzed episode will have EDL files created.

View File

@ -1,25 +0,0 @@
# Release procedure
## Run tests
1. Run unit tests with `dotnet test`
2. Run end to end tests with `JELLYFIN_TOKEN=api_key_here python3 main.py`
## Release plugin
1. Run package plugin action and download bundle
2. Combine generated `manifest.json` with main plugin manifest
3. Test plugin manifest
1. Replace manifest URL with local IP address
2. Serve release ZIP and manifest with `python3 -m http.server`
3. Test updating plugin
4. Create release on GitHub with the following files:
1. Archived plugin DLL
2. Link to the latest web interface
## Release container
1. Run publish container action
2. Update `latest` tag
1. `docker tag ghcr.io/confusedpolarbear/jellyfin-intro-skipper:{COMMIT,latest}`
2. `docker push ghcr.io/confusedpolarbear/jellyfin-intro-skipper:latest`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -1,27 +0,0 @@
if exist %UserProfile%\AppData\Local\jellyfin\plugins\ (
FOR /F "eol=| delims=" %%I IN ('DIR "%UserProfile%\AppData\Local\jellyfin\plugins\Intro Skipper*" /B /O-D /TW 2^>nul') DO (
SET "NewestFile=%UserProfile%\AppData\Local\jellyfin\plugins\%%I"
GOTO FoundFile
)
ECHO Intro Skipper plugin not found!
GOTO UserInput
)
if exist %ProgramData%\Jellyfin\Server\plugins\ (
FOR /F "eol=| delims=" %%I IN ('DIR "%ProgramData%\Jellyfin\Server\plugins\Intro Skipper*" /B /O-D /TW 2^>nul') DO (
SET "NewestFile=%ProgramData%\Jellyfin\Server\plugins\%%I"
GOTO FoundFile
)
ECHO Intro Skipper plugin not found!
GOTO UserInput
)
ECHO Jellyfin plugin directory not found!
GOTO UserInput
:FoundFile
echo "%NewestFile%"
xcopy /y ConfusedPolarBear.Plugin.IntroSkipper.dll "%NewestFile%"
:UserInput
@pause

View File

@ -1,27 +0,0 @@
if [ "$(uname)" == "Darwin" ]; then
# MacOS
if [ -d ~/.local/share/jellyfin/plugins/ ]; then
plugin=$(ls -d ~/.local/share/jellyfin/plugins/Intro\ Skipper* | sort -r | head -n 1 )
if [ -z "$plugin" ]; then
echo "Intro Skipper plugin not found!"
exit
fi
cp -f ConfusedPolarBear.Plugin.IntroSkipper*.dll \
"$plugin/ConfusedPolarBear.Plugin.IntroSkipper.dll"
else
echo "Jellyfin plugin directory not found!"
fi
elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then
# Linux
if [ -d /var/lib/jellyfin/plugins/ ]; then
plugin=$(ls -d /var/lib/jellyfin/plugins/Intro\ Skipper* | sort -r | head -n 1 )
if [ -z "$plugin' ]; then
echo "Intro Skipper plugin not found!"
exit
fi
cp -f ConfusedPolarBear.Plugin.IntroSkipper*.dll \
"$plugin/ConfusedPolarBear.Plugin.IntroSkipper.dll"
else
echo "Jellyfin plugin directory not found!"
fi
fi

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