Compare commits
109 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
3139c15eb1 | ||
|
d7d3949887 | ||
|
18d0847ae0 | ||
|
724c237592 | ||
|
1048eaf26d | ||
|
4995fc7b70 | ||
|
d4f88e0f3e | ||
|
cb0fdb92ad | ||
|
87ee56de65 | ||
|
9fce12bdbb | ||
|
4293f17dd4 | ||
|
e86832b571 | ||
|
a89e61b919 | ||
|
89d3fe79ec | ||
|
62683ede87 | ||
|
d48ea90190 | ||
|
6ccf002e51 | ||
|
6aa26fe9a7 | ||
|
b511d9045e | ||
|
a51bf5552b | ||
|
f275e188da | ||
|
7309be0422 | ||
|
78e8943061 | ||
|
86f2c7e04c | ||
|
fcaff967f0 | ||
|
0b31117772 | ||
|
6bb54ab3a5 | ||
|
72366e93b9 | ||
|
247a5793d8 | ||
|
e5f29a91c9 | ||
|
f70207d002 | ||
|
ec4863dd5d | ||
|
dcb034ff89 | ||
|
1304acc312 | ||
|
f68fd9d06a | ||
|
327fe99de0 | ||
|
6c64d48fe5 | ||
|
79151fa893 | ||
|
86dec1478a | ||
|
48a087d437 | ||
|
16b834b553 | ||
|
b9194334c6 | ||
|
da935524bd | ||
|
af1536fc28 | ||
|
97b7585efb | ||
|
9e2bbd0c0f | ||
|
4598918bd7 | ||
|
d5043d3f04 | ||
|
71768972e5 | ||
|
9af182fe98 | ||
|
94da294316 | ||
|
a1d634b66e | ||
|
29ca500ef1 | ||
|
c2eaed2f63 | ||
|
55c6415022 | ||
|
7db0f774e9 | ||
|
abb4cf44f4 | ||
|
27c4843904 | ||
|
69960bfa5b | ||
|
87875c8a11 | ||
|
f68cc3a68b | ||
|
1e93a3dca7 | ||
|
cb588df512 | ||
|
ce922d1b79 | ||
|
b286c78153 | ||
|
6d93df1c76 | ||
|
718a86945e | ||
|
6ff2185f6c | ||
|
c4347a374a | ||
|
ccdea752ef | ||
|
964822cd94 | ||
|
fc400560e6 | ||
|
e9131b6710 | ||
|
3e25778173 | ||
|
f762f0f1a2 | ||
|
1019cc30fb | ||
|
0e7d3b6aee | ||
|
2f7c2ed95e | ||
|
28f93a8569 | ||
|
479f6401f1 | ||
|
03480eaa12 | ||
|
1cc80604c2 | ||
|
b38bd7bb8c | ||
|
6f79e5d2c5 | ||
|
c663e1dfde | ||
|
b12433d621 | ||
|
6376e72862 | ||
|
f282e51308 | ||
|
299db8d77e | ||
|
21cf2d5e46 | ||
|
05038b6ead | ||
|
e20f87bb3b | ||
|
5f9a308dca | ||
|
f47f05c04e | ||
|
d9460b16fb | ||
|
0b27b6e297 | ||
|
4b725aaaad | ||
|
b02403da84 | ||
|
721de97cc1 | ||
|
84da3f0a29 | ||
|
5205965d0d | ||
|
aa1e0b8966 | ||
|
0735b41f3d | ||
|
00b272e77e | ||
|
1a731e3acc | ||
|
8f7c63172f | ||
|
3c2125ab82 | ||
|
5bc8913668 | ||
|
f6c8fca28f |
196
.editorconfig
Normal file
196
.editorconfig
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
file_header_template = Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>\nSPDX-License-Identifier: GPL-3.0-only.
|
76
.github/ISSUE_TEMPLATE/bug_report_form.yml
vendored
Normal file
76
.github/ISSUE_TEMPLATE/bug_report_form.yml
vendored
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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 actively updated. Please make sure you are using the [latest](https://github.com/jellyfin/jellyfin/releases/latest) release.
|
||||||
|
|
||||||
|
Many servers have permission issues that can be resolved with a few extra steps.
|
||||||
|
If your skip button is not shown, please see [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible) before reporting.
|
||||||
|
options:
|
||||||
|
- label: I use Jellyfin 10.10.3 (or newer) and my permissions are correct
|
||||||
|
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.9.9, 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
|
29
.github/dependabot.yml
vendored
Normal file
29
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
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: weekly
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
labels:
|
||||||
|
- ci
|
||||||
|
- dependency
|
||||||
|
- github_actions
|
||||||
|
commit-message:
|
||||||
|
prefix: ci
|
||||||
|
include: scope
|
108
.github/workflows/build.yml
vendored
Normal file
108
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
name: "Build Plugin"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '*' # Triggers on any branch push
|
||||||
|
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:
|
||||||
|
- name: Sanitize head_ref
|
||||||
|
run: |
|
||||||
|
# Get the branch name and sanitize it
|
||||||
|
SANITIZED_BRANCH_NAME=$(echo "${{ github.head_ref }}" | sed 's/[^a-zA-Z0-9.-]/_/g')
|
||||||
|
|
||||||
|
# Export it as an environment variable
|
||||||
|
echo "SANITIZED_BRANCH_NAME=$SANITIZED_BRANCH_NAME" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Read version from VERSION.txt
|
||||||
|
id: read-version
|
||||||
|
run: |
|
||||||
|
MAIN_VERSION=$(cat VERSION.txt)
|
||||||
|
echo "MAIN_VERSION=${MAIN_VERSION}"
|
||||||
|
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Check for BETA file
|
||||||
|
id: check-beta
|
||||||
|
run: |
|
||||||
|
if [ -f "BETA" ]; then
|
||||||
|
echo "IS_BETA=true" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "IS_BETA=false" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "lts/*"
|
||||||
|
|
||||||
|
- name: Minify HTML
|
||||||
|
run: |
|
||||||
|
npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o IntroSkipper/Configuration/configPage.html IntroSkipper/Configuration/configPage.html
|
||||||
|
npx terser IntroSkipper/Configuration/inject.js -o IntroSkipper/Configuration/inject.js -c -m
|
||||||
|
npx terser IntroSkipper/Configuration/visualizer.js -o IntroSkipper/Configuration/visualizer.js -c -m
|
||||||
|
|
||||||
|
- name: Restore Beta dependencies
|
||||||
|
if: ${{env.IS_BETA == 'true' }}
|
||||||
|
run: |
|
||||||
|
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
|
||||||
|
dotnet tool install --global dotnet-outdated-tool
|
||||||
|
dotnet outdated -pre Always -u -inc Jellyfin
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
if: ${{env.IS_BETA == 'false' }}
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Embed version info
|
||||||
|
run: |
|
||||||
|
GITHUB_SHA=${{ github.sha }}
|
||||||
|
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" IntroSkipper/Helper/Commit.cs
|
||||||
|
|
||||||
|
- 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.6
|
||||||
|
with:
|
||||||
|
name: IntroSkipper-${{ env.GIT_HASH }}.dll
|
||||||
|
path: IntroSkipper/bin/Debug/net8.0/IntroSkipper.dll
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Create archive
|
||||||
|
if: startsWith(github.ref_name, '10.') # only do a preview release on 10.x branches
|
||||||
|
run: zip -j "intro-skipper-${{ env.GIT_HASH }}.zip" IntroSkipper/bin/Debug/net8.0/IntroSkipper.dll
|
||||||
|
|
||||||
|
- name: Create/replace the preview release and upload artifacts
|
||||||
|
if: startsWith(github.ref_name, '10.') # only do a preview release on 10.x branches
|
||||||
|
run: |
|
||||||
|
gh release delete "${{ env.MAIN_VERSION }}/preview" --cleanup-tag --yes || true
|
||||||
|
gh release create "${{ env.MAIN_VERSION }}/preview" "intro-skipper-${{ env.GIT_HASH }}.zip" --prerelease --title "intro-skipper-${{ env.GIT_HASH }}" --notes "This is a prerelease version." --target ${{ env.MAIN_VERSION }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
67
.github/workflows/codeql.yml
vendored
Normal file
67
.github/workflows/codeql.yml
vendored
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '*' # Triggers on any branch push
|
||||||
|
paths-ignore:
|
||||||
|
- "**/README.md"
|
||||||
|
- ".github/ISSUE_TEMPLATE/**"
|
||||||
|
- "docs/**"
|
||||||
|
- "images/**"
|
||||||
|
- "manifest.json"
|
||||||
|
|
||||||
|
permissions: write-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# This job will only run if the repository is public
|
||||||
|
if: ${{ github.event.repository.private == false }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: ["csharp"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check for BETA file
|
||||||
|
id: check-beta
|
||||||
|
run: |
|
||||||
|
if [ -f "BETA" ]; then
|
||||||
|
echo "IS_BETA=true" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "IS_BETA=false" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
|
- name: Restore Beta dependencies
|
||||||
|
if: ${{env.IS_BETA == 'true' }}
|
||||||
|
run: |
|
||||||
|
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
|
||||||
|
dotnet tool install --global dotnet-outdated-tool
|
||||||
|
dotnet outdated -pre Always -u -inc Jellyfin
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
if: ${{env.IS_BETA == 'false' }}
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
queries: +security-extended
|
||||||
|
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5
|
103
.github/workflows/release.yml
vendored
Normal file
103
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
name: "Release Plugin"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Read version from VERSION.txt
|
||||||
|
id: read-version
|
||||||
|
run: |
|
||||||
|
MAIN_VERSION=$(cat VERSION.txt)
|
||||||
|
echo "MAIN_VERSION=${MAIN_VERSION}"
|
||||||
|
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Check for BETA file
|
||||||
|
id: check-beta
|
||||||
|
run: |
|
||||||
|
if [ -f "BETA" ]; then
|
||||||
|
echo "IS_BETA=true" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "IS_BETA=false" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "lts/*"
|
||||||
|
|
||||||
|
- name: Minify HTML
|
||||||
|
run: |
|
||||||
|
npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o IntroSkipper/Configuration/configPage.html IntroSkipper/Configuration/configPage.html
|
||||||
|
npx terser IntroSkipper/Configuration/inject.js -o IntroSkipper/Configuration/inject.js -c -m
|
||||||
|
npx terser IntroSkipper/Configuration/visualizer.js -o IntroSkipper/Configuration/visualizer.js -c -m
|
||||||
|
|
||||||
|
- name: Restore Beta dependencies
|
||||||
|
if: ${{env.IS_BETA == 'true' }}
|
||||||
|
run: |
|
||||||
|
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
|
||||||
|
dotnet tool install --global dotnet-outdated-tool
|
||||||
|
dotnet outdated -pre Always -u -inc Jellyfin
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
if: ${{env.IS_BETA == 'false' }}
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Run update version
|
||||||
|
uses: intro-skipper/intro-skipper-action-ts@main
|
||||||
|
with:
|
||||||
|
task-type: "updateVersion"
|
||||||
|
|
||||||
|
- name: Embed version info
|
||||||
|
run: |
|
||||||
|
GITHUB_SHA=${{ github.sha }}
|
||||||
|
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" IntroSkipper/Helper/Commit.cs
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build --configuration Release --no-restore
|
||||||
|
|
||||||
|
- name: Create archive
|
||||||
|
run: zip -j "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" IntroSkipper/bin/Release/net8.0/IntroSkipper.dll
|
||||||
|
|
||||||
|
- name: Remove old release if exits
|
||||||
|
if: ${{ github.repository == 'intro-skipper/intro-skipper-test' }}
|
||||||
|
run: gh release delete "${{ env.MAIN_VERSION }}/v${{ env.NEW_FILE_VERSION }}" --cleanup-tag --yes || true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create new release with tag
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: gh release create "${{ env.MAIN_VERSION }}/v${{ env.NEW_FILE_VERSION }}" "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" --title "v${{ env.NEW_FILE_VERSION }}" --latest --generate-notes --target ${{ env.MAIN_VERSION }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Run validation and update script
|
||||||
|
uses: intro-skipper/intro-skipper-action-ts@main
|
||||||
|
with:
|
||||||
|
task-type: "updateManifest"
|
||||||
|
env:
|
||||||
|
GITHUB_REPO_VISIBILITY: ${{ github.event.repository.visibility }}
|
||||||
|
MAIN_VERSION: ${{ env.MAIN_VERSION }}
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
git config --global user.name "github-actions[bot]"
|
||||||
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add README.md manifest.json IntroSkipper/IntroSkipper.csproj .github/ISSUE_TEMPLATE/bug_report_form.yml
|
||||||
|
git commit -m "release v${{ env.NEW_FILE_VERSION }}"
|
||||||
|
git push
|
41
.github/workflows/webui.yml
vendored
Normal file
41
.github/workflows/webui.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
name: Create Jellyfin-web artifact
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
jellyfin-web-version: [10.9.11]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Node.js environment
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ">=20"
|
||||||
|
- name: Checkout official jellyfin-web
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: jellyfin/jellyfin-web
|
||||||
|
ref: v${{ matrix.jellyfin-web-version }}
|
||||||
|
path: web
|
||||||
|
- name: Apply intro skipper patch
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
git apply ../webui.patch
|
||||||
|
- name: Build web interface
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
npm ci --no-audit
|
||||||
|
npm run build:production
|
||||||
|
- name: Upload web interface
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: jellyfin-web-${{ matrix.jellyfin-web-version }}+${{ github.sha }}
|
||||||
|
path: web/dist
|
||||||
|
if-no-files-found: error
|
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
/package/
|
||||||
|
|
||||||
|
# Ignore pre compiled web interface
|
||||||
|
docker/dist
|
||||||
|
|
||||||
|
# Visual Studio
|
||||||
|
.vs/
|
||||||
|
|
||||||
|
# JetBrains Rider
|
||||||
|
.idea/
|
11
.vscode/extensions.json
vendored
Normal file
11
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"ms-dotnettools.csharp",
|
||||||
|
"editorconfig.editorconfig",
|
||||||
|
"github.vscode-github-actions",
|
||||||
|
"ms-dotnettools.vscode-dotnet-runtime",
|
||||||
|
"ms-dotnettools.csdevkit",
|
||||||
|
"eamodio.gitlens"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"editor.insertSpaces": true,
|
||||||
|
"editor.tabSize": 4,
|
||||||
|
"files.encoding": "utf8",
|
||||||
|
"files.trimTrailingWhitespace": true,
|
||||||
|
"files.insertFinalNewline": true,
|
||||||
|
"editor.rulers": [],
|
||||||
|
"dotnet.defaultSolution": "IntroSkipper.sln"
|
||||||
|
}
|
7
ACKNOWLEDGEMENTS.md
Normal file
7
ACKNOWLEDGEMENTS.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
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)
|
27
IntroSkipper.Tests/IntroSkipper.Tests.csproj
Normal file
27
IntroSkipper.Tests/IntroSkipper.Tests.csproj
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<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.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<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="..\IntroSkipper\IntroSkipper.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
168
IntroSkipper.Tests/TestAudioFingerprinting.cs
Normal file
168
IntroSkipper.Tests/TestAudioFingerprinting.cs
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
/* These tests require that the host system has a version of FFmpeg installed
|
||||||
|
* which supports both chromaprint and the "-fp_format raw" flag.
|
||||||
|
*/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using IntroSkipper.Analyzers;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace 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)
|
||||||
|
{
|
||||||
|
Assert.Equal(expectedBits, ChromaprintAnalyzer.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 analyzer = CreateChromaprintAnalyzer();
|
||||||
|
var actual = analyzer.CreateInvertedIndex(Guid.NewGuid(), fpr);
|
||||||
|
|
||||||
|
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.Start);
|
||||||
|
Assert.Equal(17.208, lhs.End, 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.Start);
|
||||||
|
Assert.Equal(22.1602, rhs.End);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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(44.631042, 44.807167),
|
||||||
|
new(53.590521, 53.806979),
|
||||||
|
new(53.845833, 54.202417),
|
||||||
|
new(54.261104, 54.593479),
|
||||||
|
new(54.709792, 54.929312),
|
||||||
|
new(54.929396, 55.258979),
|
||||||
|
};
|
||||||
|
|
||||||
|
var range = new TimeRange(0, 60);
|
||||||
|
var actual = FFmpegWrapper.DetectSilence(clip, range);
|
||||||
|
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QueuedEpisode QueueEpisode(string path)
|
||||||
|
{
|
||||||
|
return new QueuedEpisode()
|
||||||
|
{
|
||||||
|
EpisodeId = Guid.NewGuid(),
|
||||||
|
Path = "../../../" + path,
|
||||||
|
IntroFingerprintEnd = 60
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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
|
||||||
|
}
|
78
IntroSkipper.Tests/TestBlackFrames.cs
Normal file
78
IntroSkipper.Tests/TestBlackFrames.cs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace IntroSkipper.Tests;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using IntroSkipper.Analyzers;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
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, 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, 85);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.InRange(result.Start, 300 - range, 300 + range);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QueuedEpisode QueueFile(string path)
|
||||||
|
{
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
EpisodeId = Guid.NewGuid(),
|
||||||
|
Name = path,
|
||||||
|
Path = "../../../video/" + path
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BlackFrameAnalyzer CreateBlackFrameAnalyzer()
|
||||||
|
{
|
||||||
|
var logger = new LoggerFactory().CreateLogger<BlackFrameAnalyzer>();
|
||||||
|
return new(logger);
|
||||||
|
}
|
||||||
|
}
|
88
IntroSkipper.Tests/TestChapterAnalyzer.cs
Normal file
88
IntroSkipper.Tests/TestChapterAnalyzer.cs
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace IntroSkipper.Tests;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using IntroSkipper.Analyzers;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
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.Start);
|
||||||
|
Assert.Equal(90, introChapter.End);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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.Start);
|
||||||
|
Assert.Equal(2000, creditsChapter.End);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Segment? 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 static ChapterInfo CreateChapter(string name, int position)
|
||||||
|
{
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
StartPositionTicks = TimeSpan.FromSeconds(position).Ticks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
96
IntroSkipper.Tests/TestContiguous.cs
Normal file
96
IntroSkipper.Tests/TestContiguous.cs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace 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));
|
||||||
|
}
|
||||||
|
}
|
52
IntroSkipper.Tests/TestWarnings.cs
Normal file
52
IntroSkipper.Tests/TestWarnings.cs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace IntroSkipper.Tests;
|
||||||
|
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
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());
|
||||||
|
Assert.True(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TestDoubleFlagSerialization()
|
||||||
|
{
|
||||||
|
WarningManager.Clear();
|
||||||
|
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
|
||||||
|
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
|
||||||
|
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
|
||||||
|
Assert.True(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton) && WarningManager.HasFlag(PluginWarning.InvalidChromaprintFingerprint));
|
||||||
|
Assert.Equal(
|
||||||
|
"UnableToAddSkipButton, InvalidChromaprintFingerprint",
|
||||||
|
WarningManager.GetWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TestHasFlag()
|
||||||
|
{
|
||||||
|
WarningManager.Clear();
|
||||||
|
Assert.True(WarningManager.HasFlag(PluginWarning.None));
|
||||||
|
Assert.False(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton) && WarningManager.HasFlag(PluginWarning.InvalidChromaprintFingerprint));
|
||||||
|
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
|
||||||
|
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
|
||||||
|
Assert.True(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton) && WarningManager.HasFlag(PluginWarning.InvalidChromaprintFingerprint));
|
||||||
|
Assert.False(WarningManager.HasFlag(PluginWarning.IncompatibleFFmpegBuild));
|
||||||
|
Assert.True(WarningManager.HasFlag(PluginWarning.None));
|
||||||
|
}
|
||||||
|
}
|
7
IntroSkipper.Tests/audio/README.txt
Normal file
7
IntroSkipper.Tests/audio/README.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
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.
|
BIN
IntroSkipper.Tests/audio/big_buck_bunny_clip.mp3
Normal file
BIN
IntroSkipper.Tests/audio/big_buck_bunny_clip.mp3
Normal file
Binary file not shown.
BIN
IntroSkipper.Tests/audio/big_buck_bunny_intro.mp3
Normal file
BIN
IntroSkipper.Tests/audio/big_buck_bunny_intro.mp3
Normal file
Binary file not shown.
14
IntroSkipper.Tests/e2e_tests/.gitignore
vendored
Normal file
14
IntroSkipper.Tests/e2e_tests/.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Binaries
|
||||||
|
/verifier/verifier
|
||||||
|
/run_tests
|
||||||
|
/plugin_binaries/
|
||||||
|
|
||||||
|
# Wrapper configuration and base configuration files
|
||||||
|
config.json
|
||||||
|
/config/
|
||||||
|
|
||||||
|
# Timestamp reports
|
||||||
|
/reports/
|
||||||
|
|
||||||
|
# Selenium screenshots
|
||||||
|
selenium/screenshots/
|
51
IntroSkipper.Tests/e2e_tests/README.md
Normal file
51
IntroSkipper.Tests/e2e_tests/README.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# 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
|
||||||
|
* 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
|
10
IntroSkipper.Tests/e2e_tests/build.sh
Normal file
10
IntroSkipper.Tests/e2e_tests/build.sh
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
#!/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"
|
22
IntroSkipper.Tests/e2e_tests/config_sample.jsonc
Normal file
22
IntroSkipper.Tests/e2e_tests/config_sample.jsonc
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
33
IntroSkipper.Tests/e2e_tests/docker-compose.yml
Normal file
33
IntroSkipper.Tests/e2e_tests/docker-compose.yml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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
|
0
IntroSkipper.Tests/e2e_tests/reports/.keep
Normal file
0
IntroSkipper.Tests/e2e_tests/reports/.keep
Normal file
203
IntroSkipper.Tests/e2e_tests/selenium/main.py
Normal file
203
IntroSkipper.Tests/e2e_tests/selenium/main.py
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
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()
|
1
IntroSkipper.Tests/e2e_tests/selenium/requirements.txt
Normal file
1
IntroSkipper.Tests/e2e_tests/selenium/requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
selenium >= 4.3.0
|
3
IntroSkipper.Tests/e2e_tests/verifier/go.mod
Normal file
3
IntroSkipper.Tests/e2e_tests/verifier/go.mod
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module github.com/confusedpolarbear/intro_skipper_verifier
|
||||||
|
|
||||||
|
go 1.17
|
78
IntroSkipper.Tests/e2e_tests/verifier/http.go
Normal file
78
IntroSkipper.Tests/e2e_tests/verifier/http.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
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
|
||||||
|
}
|
63
IntroSkipper.Tests/e2e_tests/verifier/main.go
Normal file
63
IntroSkipper.Tests/e2e_tests/verifier/main.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
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()
|
||||||
|
}
|
389
IntroSkipper.Tests/e2e_tests/verifier/report.html
Normal file
389
IntroSkipper.Tests/e2e_tests/verifier/report.html
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
<!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>
|
139
IntroSkipper.Tests/e2e_tests/verifier/report_comparison.go
Normal file
139
IntroSkipper.Tests/e2e_tests/verifier/report_comparison.go
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
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
|
||||||
|
}
|
@ -0,0 +1,81 @@
|
|||||||
|
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
|
||||||
|
}
|
180
IntroSkipper.Tests/e2e_tests/verifier/report_generator.go
Normal file
180
IntroSkipper.Tests/e2e_tests/verifier/report_generator.go
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
116
IntroSkipper.Tests/e2e_tests/verifier/schema_validation.go
Normal file
116
IntroSkipper.Tests/e2e_tests/verifier/schema_validation.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
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
|
||||||
|
}
|
20
IntroSkipper.Tests/e2e_tests/verifier/structs/intro.go
Normal file
20
IntroSkipper.Tests/e2e_tests/verifier/structs/intro.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
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)
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package structs
|
||||||
|
|
||||||
|
type PublicInfo struct {
|
||||||
|
Version string
|
||||||
|
OperatingSystem string
|
||||||
|
}
|
48
IntroSkipper.Tests/e2e_tests/verifier/structs/report.go
Normal file
48
IntroSkipper.Tests/e2e_tests/verifier/structs/report.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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
|
||||||
|
}
|
64
IntroSkipper.Tests/e2e_tests/wrapper/exec.go
Normal file
64
IntroSkipper.Tests/e2e_tests/wrapper/exec.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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")
|
||||||
|
}
|
13
IntroSkipper.Tests/e2e_tests/wrapper/exec_test.go
Normal file
13
IntroSkipper.Tests/e2e_tests/wrapper/exec_test.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
3
IntroSkipper.Tests/e2e_tests/wrapper/go.mod
Normal file
3
IntroSkipper.Tests/e2e_tests/wrapper/go.mod
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module github.com/confusedpolarbear/intro_skipper_wrapper
|
||||||
|
|
||||||
|
go 1.17
|
93
IntroSkipper.Tests/e2e_tests/wrapper/library.json
Normal file
93
IntroSkipper.Tests/e2e_tests/wrapper/library.json
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
380
IntroSkipper.Tests/e2e_tests/wrapper/main.go
Normal file
380
IntroSkipper.Tests/e2e_tests/wrapper/main.go
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
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
|
||||||
|
}
|
79
IntroSkipper.Tests/e2e_tests/wrapper/setup.go
Normal file
79
IntroSkipper.Tests/e2e_tests/wrapper/setup.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
26
IntroSkipper.Tests/e2e_tests/wrapper/structs.go
Normal file
26
IntroSkipper.Tests/e2e_tests/wrapper/structs.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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:"-"`
|
||||||
|
}
|
BIN
IntroSkipper.Tests/video/credits.mp4
Normal file
BIN
IntroSkipper.Tests/video/credits.mp4
Normal file
Binary file not shown.
BIN
IntroSkipper.Tests/video/rainbow.mp4
Normal file
BIN
IntroSkipper.Tests/video/rainbow.mp4
Normal file
Binary file not shown.
22
IntroSkipper.sln
Normal file
22
IntroSkipper.sln
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
#
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntroSkipper", "IntroSkipper\IntroSkipper.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntroSkipper.Tests", "IntroSkipper.Tests\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
|
218
IntroSkipper/Analyzers/BlackFrameAnalyzer.cs
Normal file
218
IntroSkipper/Analyzers/BlackFrameAnalyzer.cs
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Configuration;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Analyzers;
|
||||||
|
|
||||||
|
/// <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(ILogger<BlackFrameAnalyzer> logger) : IMediaFileAnalyzer
|
||||||
|
{
|
||||||
|
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
private readonly TimeSpan _maximumError = new(0, 0, 4);
|
||||||
|
private readonly ILogger<BlackFrameAnalyzer> _logger = logger;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
||||||
|
IReadOnlyList<QueuedEpisode> analysisQueue,
|
||||||
|
AnalysisMode mode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (mode != AnalysisMode.Credits)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException("mode must equal Credits");
|
||||||
|
}
|
||||||
|
|
||||||
|
var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
|
||||||
|
|
||||||
|
var searchStart = 0.0;
|
||||||
|
|
||||||
|
foreach (var episode in episodesWithoutIntros)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AnalyzeChapters(episode, out var credit))
|
||||||
|
{
|
||||||
|
if (searchStart < _config.MinimumCreditsDuration)
|
||||||
|
{
|
||||||
|
searchStart = FindSearchStart(episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
credit = AnalyzeMediaFile(
|
||||||
|
episode,
|
||||||
|
searchStart,
|
||||||
|
_config.BlackFrameMinimumPercentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credit is null || !credit.Valid)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
episode.IsAnalyzed = true;
|
||||||
|
await Plugin.Instance!.UpdateTimestampAsync(credit, mode).ConfigureAwait(false);
|
||||||
|
searchStart = episode.Duration - credit.Start + _config.MinimumCreditsDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return analysisQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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="minimum">Percentage of the frame that must be black.</param>
|
||||||
|
/// <returns>Credits timestamp.</returns>
|
||||||
|
public Segment? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int minimum)
|
||||||
|
{
|
||||||
|
// Start by analyzing the last N minutes of the file.
|
||||||
|
var searchDistance = 2 * _config.MinimumCreditsDuration;
|
||||||
|
var upperLimit = searchStart;
|
||||||
|
var lowerLimit = Math.Max(searchStart - searchDistance, _config.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), _config.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), episode.Duration - episode.CreditsFingerprintStart);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool AnalyzeChapters(QueuedEpisode episode, out Segment? segment)
|
||||||
|
{
|
||||||
|
// Get last chapter that falls within the valid credits duration range
|
||||||
|
var suitableChapters = Plugin.Instance!.GetChapters(episode.EpisodeId)
|
||||||
|
.Select(c => TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds)
|
||||||
|
.Where(s => s >= episode.CreditsFingerprintStart &&
|
||||||
|
s <= episode.Duration - _config.MinimumCreditsDuration)
|
||||||
|
.OrderByDescending(s => s).ToList();
|
||||||
|
|
||||||
|
// If suitable chapters found, use them to find the search start point
|
||||||
|
foreach (var chapterStart in suitableChapters)
|
||||||
|
{
|
||||||
|
// Check for black frames at chapter start
|
||||||
|
var startRange = new TimeRange(chapterStart, chapterStart + 1);
|
||||||
|
var hasBlackFramesAtStart = FFmpegWrapper.DetectBlackFrames(
|
||||||
|
episode,
|
||||||
|
startRange,
|
||||||
|
_config.BlackFrameMinimumPercentage).Length > 0;
|
||||||
|
|
||||||
|
if (!hasBlackFramesAtStart)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no black frames before chapter start
|
||||||
|
var beforeRange = new TimeRange(chapterStart - 5, chapterStart - 4);
|
||||||
|
var hasBlackFramesBefore = FFmpegWrapper.DetectBlackFrames(
|
||||||
|
episode,
|
||||||
|
beforeRange,
|
||||||
|
_config.BlackFrameMinimumPercentage).Length > 0;
|
||||||
|
|
||||||
|
if (!hasBlackFramesBefore)
|
||||||
|
{
|
||||||
|
segment = new(episode.EpisodeId, new TimeRange(chapterStart, episode.Duration));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segment = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double FindSearchStart(QueuedEpisode episode)
|
||||||
|
{
|
||||||
|
var searchStart = 3 * _config.MinimumCreditsDuration;
|
||||||
|
var scanTime = episode.Duration - searchStart;
|
||||||
|
var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here.
|
||||||
|
|
||||||
|
// Keep increasing search start time while black frames are found, to avoid false positives
|
||||||
|
while (FFmpegWrapper.DetectBlackFrames(episode, tr, _config.BlackFrameMinimumPercentage).Length > 0)
|
||||||
|
{
|
||||||
|
// Increase by 2x minimum credits duration each iteration
|
||||||
|
searchStart += 2 * _config.MinimumCreditsDuration;
|
||||||
|
scanTime = episode.Duration - searchStart;
|
||||||
|
tr = new TimeRange(scanTime - 0.5, scanTime);
|
||||||
|
|
||||||
|
// Don't search past the required credits duration from the end
|
||||||
|
if (searchStart > episode.Duration - episode.CreditsFingerprintStart)
|
||||||
|
{
|
||||||
|
searchStart = episode.Duration - episode.CreditsFingerprintStart;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchStart;
|
||||||
|
}
|
||||||
|
}
|
172
IntroSkipper/Analyzers/ChapterAnalyzer.cs
Normal file
172
IntroSkipper/Analyzers/ChapterAnalyzer.cs
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Configuration;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Analyzers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chapter name analyzer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="ChapterAnalyzer"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyzer
|
||||||
|
{
|
||||||
|
private readonly ILogger<ChapterAnalyzer> _logger = logger;
|
||||||
|
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
||||||
|
IReadOnlyList<QueuedEpisode> analysisQueue,
|
||||||
|
AnalysisMode mode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var expression = mode switch
|
||||||
|
{
|
||||||
|
AnalysisMode.Introduction => _config.ChapterAnalyzerIntroductionPattern,
|
||||||
|
AnalysisMode.Credits => _config.ChapterAnalyzerEndCreditsPattern,
|
||||||
|
AnalysisMode.Recap => _config.ChapterAnalyzerRecapPattern,
|
||||||
|
AnalysisMode.Preview => _config.ChapterAnalyzerPreviewPattern,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(mode), $"Unexpected analysis mode: {mode}")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(expression))
|
||||||
|
{
|
||||||
|
return analysisQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
|
||||||
|
|
||||||
|
foreach (var episode in episodesWithoutIntros)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var skipRange = FindMatchingChapter(
|
||||||
|
episode,
|
||||||
|
Plugin.Instance!.GetChapters(episode.EpisodeId),
|
||||||
|
expression,
|
||||||
|
mode);
|
||||||
|
|
||||||
|
if (skipRange is null || !skipRange.Valid)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
episode.IsAnalyzed = true;
|
||||||
|
await Plugin.Instance!.UpdateTimestampAsync(skipRange, mode).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return analysisQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 Segment? FindMatchingChapter(
|
||||||
|
QueuedEpisode episode,
|
||||||
|
IReadOnlyList<ChapterInfo> chapters,
|
||||||
|
string expression,
|
||||||
|
AnalysisMode mode)
|
||||||
|
{
|
||||||
|
var count = chapters.Count;
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var creditDuration = episode.IsMovie ? _config.MaximumMovieCreditsDuration : _config.MaximumCreditsDuration;
|
||||||
|
var reversed = mode == AnalysisMode.Credits;
|
||||||
|
var (minDuration, maxDuration) = reversed
|
||||||
|
? (_config.MinimumCreditsDuration, creditDuration)
|
||||||
|
: (_config.MinimumIntroDuration, _config.MaximumIntroDuration);
|
||||||
|
|
||||||
|
// Check all chapters
|
||||||
|
for (int i = reversed ? count - 1 : 0; reversed ? i >= 0 : i < count; i += reversed ? -1 : 1)
|
||||||
|
{
|
||||||
|
var chapter = chapters[i];
|
||||||
|
var next = chapters.ElementAtOrDefault(i + 1) ??
|
||||||
|
new ChapterInfo { StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks }; // Since the ending credits chapter may be the last chapter in the file, append a virtual chapter.
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(chapter.Name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentRange = new TimeRange(
|
||||||
|
TimeSpan.FromTicks(chapter.StartPositionTicks).TotalSeconds,
|
||||||
|
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
|
||||||
|
|
||||||
|
var baseMessage = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}: Chapter \"{1}\" ({2} - {3})",
|
||||||
|
episode.Path,
|
||||||
|
chapter.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(
|
||||||
|
chapter.Name,
|
||||||
|
expression,
|
||||||
|
RegexOptions.IgnoreCase,
|
||||||
|
TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
if (!match)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the next (or previous for Credits) chapter also matches
|
||||||
|
var adjacentChapter = reversed ? chapters.ElementAtOrDefault(i - 1) : next;
|
||||||
|
if (adjacentChapter != null && !string.IsNullOrWhiteSpace(adjacentChapter.Name))
|
||||||
|
{
|
||||||
|
// Check for possibility of overlapping keywords
|
||||||
|
var overlap = Regex.IsMatch(
|
||||||
|
adjacentChapter.Name,
|
||||||
|
expression,
|
||||||
|
RegexOptions.None,
|
||||||
|
TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
if (overlap)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("{Base}: ignoring (adjacent chapter also matches)", baseMessage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogTrace("{Base}: okay", baseMessage);
|
||||||
|
return new Segment(episode.EpisodeId, currentRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
494
IntroSkipper/Analyzers/ChromaprintAnalyzer.cs
Normal file
494
IntroSkipper/Analyzers/ChromaprintAnalyzer.cs
Normal file
@ -0,0 +1,494 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Configuration;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Analyzers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : 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 readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
private readonly ILogger<ChromaprintAnalyzer> _logger = logger;
|
||||||
|
private readonly Dictionary<Guid, Dictionary<uint, int>> _invertedIndexCache = [];
|
||||||
|
private AnalysisMode _analysisMode;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
||||||
|
IReadOnlyList<QueuedEpisode> analysisQueue,
|
||||||
|
AnalysisMode mode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Episodes that were not analyzed.
|
||||||
|
var episodeAnalysisQueue = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
|
||||||
|
|
||||||
|
if (episodeAnalysisQueue.Count <= 1)
|
||||||
|
{
|
||||||
|
return analysisQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_analysisMode = mode;
|
||||||
|
|
||||||
|
// All intros for this season.
|
||||||
|
var seasonIntros = new Dictionary<Guid, Segment>();
|
||||||
|
|
||||||
|
// Cache of all fingerprints for this season.
|
||||||
|
var fingerprintCache = new Dictionary<Guid, uint[]>();
|
||||||
|
|
||||||
|
// 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] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ||
|
||||||
|
(_analysisMode == AnalysisMode.Introduction && 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 (_analysisMode == AnalysisMode.Credits)
|
||||||
|
{
|
||||||
|
// Calculate new values for the current intro
|
||||||
|
double currentOriginalIntroStart = currentIntro.Start;
|
||||||
|
currentIntro.Start = currentEpisode.Duration - currentIntro.End;
|
||||||
|
currentIntro.End = currentEpisode.Duration - currentOriginalIntroStart;
|
||||||
|
|
||||||
|
// Calculate new values for the remaining intro
|
||||||
|
double remainingIntroOriginalStart = remainingIntro.Start;
|
||||||
|
remainingIntro.Start = remainingEpisode.Duration - remainingIntro.End;
|
||||||
|
remainingIntro.End = 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 an intro is found for this episode, adjust its times and save it else add it to the list of episodes without intros.
|
||||||
|
if (seasonIntros.TryGetValue(currentEpisode.EpisodeId, out var intro))
|
||||||
|
{
|
||||||
|
currentEpisode.IsAnalyzed = true;
|
||||||
|
await Plugin.Instance!.UpdateTimestampAsync(intro, mode).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return analysisQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 (Segment Lhs, Segment 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 Segment(lhsId), new Segment(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 static (Segment Lhs, Segment 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 Segment(lhsId, lhsIntro), new Segment(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 = CreateInvertedIndex(lhsId, lhsPoints);
|
||||||
|
var rhsIndex = CreateInvertedIndex(rhsId, rhsPoints);
|
||||||
|
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 * _config.InvertedIndexShift; i <= _config.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) > _config.MaximumFingerprintPointDifferences)
|
||||||
|
{
|
||||||
|
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], _config.MaximumTimeSkip);
|
||||||
|
if (lContiguous is null || lContiguous.Duration < _config.MinimumIntroDuration)
|
||||||
|
{
|
||||||
|
return (new TimeRange(), new TimeRange());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since LHS had a contiguous time range, RHS must have one also.
|
||||||
|
var rContiguous = TimeRangeHelpers.FindContiguous([.. rhsTimes], _config.MaximumTimeSkip)!;
|
||||||
|
return (lContiguous, rContiguous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adjusts the end timestamps of all intros so that they end at silence.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="episode">QueuedEpisode to adjust.</param>
|
||||||
|
/// <param name="originalIntro">Original introduction.</param>
|
||||||
|
private Segment AdjustIntroTimes(
|
||||||
|
QueuedEpisode episode,
|
||||||
|
Segment originalIntro)
|
||||||
|
{
|
||||||
|
_logger.LogTrace(
|
||||||
|
"{Name} original intro: {Start} - {End}",
|
||||||
|
episode.Name,
|
||||||
|
originalIntro.Start,
|
||||||
|
originalIntro.End);
|
||||||
|
|
||||||
|
var originalIntroStart = new TimeRange(
|
||||||
|
Math.Max(0, (int)originalIntro.Start - 5),
|
||||||
|
(int)originalIntro.Start + 10);
|
||||||
|
|
||||||
|
var originalIntroEnd = new TimeRange(
|
||||||
|
(int)originalIntro.End - 10,
|
||||||
|
Math.Min(episode.Duration, (int)originalIntro.End + 5));
|
||||||
|
|
||||||
|
// Try to adjust based on chapters first, fall back to silence detection for intros
|
||||||
|
if (!AdjustIntroBasedOnChapters(episode, originalIntro, originalIntroStart, originalIntroEnd) &&
|
||||||
|
_analysisMode == AnalysisMode.Introduction)
|
||||||
|
{
|
||||||
|
AdjustIntroBasedOnSilence(episode, originalIntro, originalIntroEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogTrace(
|
||||||
|
"{Name} adjusted intro: {Start} - {End}",
|
||||||
|
episode.Name,
|
||||||
|
originalIntro.Start,
|
||||||
|
originalIntro.End);
|
||||||
|
|
||||||
|
return originalIntro;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool AdjustIntroBasedOnChapters(
|
||||||
|
QueuedEpisode episode,
|
||||||
|
Segment intro,
|
||||||
|
TimeRange originalIntroStart,
|
||||||
|
TimeRange originalIntroEnd)
|
||||||
|
{
|
||||||
|
var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? [];
|
||||||
|
double previousTime = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i <= chapters.Count; i++)
|
||||||
|
{
|
||||||
|
double currentTime = i < chapters.Count
|
||||||
|
? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds
|
||||||
|
: episode.Duration;
|
||||||
|
|
||||||
|
if (IsTimeWithinRange(previousTime, originalIntroStart))
|
||||||
|
{
|
||||||
|
intro.Start = previousTime;
|
||||||
|
_logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTimeWithinRange(currentTime, originalIntroEnd))
|
||||||
|
{
|
||||||
|
intro.End = currentTime;
|
||||||
|
_logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousTime = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment intro, TimeRange originalIntroEnd)
|
||||||
|
{
|
||||||
|
var silenceRanges = FFmpegWrapper.DetectSilence(episode, originalIntroEnd);
|
||||||
|
|
||||||
|
foreach (var silenceRange in silenceRanges)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, silenceRange.Start, silenceRange.End);
|
||||||
|
|
||||||
|
if (IsValidSilenceForIntroAdjustment(silenceRange, originalIntroEnd, intro))
|
||||||
|
{
|
||||||
|
intro.End = silenceRange.Start;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsValidSilenceForIntroAdjustment(
|
||||||
|
TimeRange silenceRange,
|
||||||
|
TimeRange originalIntroEnd,
|
||||||
|
Segment adjustedIntro)
|
||||||
|
{
|
||||||
|
return originalIntroEnd.Intersects(silenceRange) &&
|
||||||
|
silenceRange.Duration >= _config.SilenceDetectionMinimumDuration &&
|
||||||
|
silenceRange.Start >= adjustedIntro.Start;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTimeWithinRange(double time, TimeRange range)
|
||||||
|
{
|
||||||
|
return range.Start < time && time < range.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>
|
||||||
|
/// <returns>Inverted index.</returns>
|
||||||
|
public Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint)
|
||||||
|
{
|
||||||
|
if (_invertedIndexCache.TryGetValue(id, 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] = invIndex;
|
||||||
|
|
||||||
|
return invIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 static int CountBits(uint number)
|
||||||
|
{
|
||||||
|
return BitOperations.PopCount(number);
|
||||||
|
}
|
||||||
|
}
|
27
IntroSkipper/Analyzers/IMediaFileAnalyzer.cs
Normal file
27
IntroSkipper/Analyzers/IMediaFileAnalyzer.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Analyzers;
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
||||||
|
IReadOnlyList<QueuedEpisode> analysisQueue,
|
||||||
|
AnalysisMode mode,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
302
IntroSkipper/Configuration/PluginConfiguration.cs
Normal file
302
IntroSkipper/Configuration/PluginConfiguration.cs
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
|
namespace 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 comma separated list of library names to analyze.
|
||||||
|
/// </summary>
|
||||||
|
public string SelectedLibraries { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether all libraries should be analyzed.
|
||||||
|
/// </summary>
|
||||||
|
public bool SelectAllLibraries { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether movies should be analyzed.
|
||||||
|
/// </summary>
|
||||||
|
public bool AnalyzeMovies { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of client to auto skip for.
|
||||||
|
/// </summary>
|
||||||
|
public string ClientList { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to automatically scan newly added items.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoDetectIntros { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to analyze season 0.
|
||||||
|
/// </summary>
|
||||||
|
public bool AnalyzeSeasonZero { get; set; }
|
||||||
|
|
||||||
|
/// <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 WithChromaprint { get; set; } = true;
|
||||||
|
|
||||||
|
// ===== Media Segment handling =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to update Media Segments.
|
||||||
|
/// </summary>
|
||||||
|
public bool UpdateMediaSegments { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to regenerate all Media Segments during the next scan.
|
||||||
|
/// By default, Media Segments are only written for a season if the season had at least one newly analyzed episode.
|
||||||
|
/// If this is set, all Media Segments will be regenerated and overwrite any existing Media Segemnts.
|
||||||
|
/// </summary>
|
||||||
|
public bool RebuildMediaSegments { get; set; } = true;
|
||||||
|
|
||||||
|
// ===== Custom analysis settings =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether Introductions should be analyzed.
|
||||||
|
/// </summary>
|
||||||
|
public bool ScanIntroduction { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether Credits should be analyzed.
|
||||||
|
/// </summary>
|
||||||
|
public bool ScanCredits { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether Recaps should be analyzed.
|
||||||
|
/// </summary>
|
||||||
|
public bool ScanRecap { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether Previews should be analyzed.
|
||||||
|
/// </summary>
|
||||||
|
public bool ScanPreview { get; set; } = true;
|
||||||
|
|
||||||
|
/// <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; } = 450;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the upper limit (in seconds) on the length of a movie segment that will be analyzed when searching for ending credits.
|
||||||
|
/// </summary>
|
||||||
|
public int MaximumMovieCreditsDuration { get; set; } = 900;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
|
||||||
|
/// </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)(?!\sEnd)(\s|$)";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the regular expression used to detect ending credit chapters.
|
||||||
|
/// </summary>
|
||||||
|
public string ChapterAnalyzerEndCreditsPattern { get; set; } =
|
||||||
|
@"(^|\s)(Credits?|ED|Ending|Outro)(?!\sEnd)(\s|$)";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the regular expression used to detect Preview chapters.
|
||||||
|
/// </summary>
|
||||||
|
public string ChapterAnalyzerPreviewPattern { get; set; } =
|
||||||
|
@"(^|\s)(Preview|PV|Sneak\s?Peek|Coming\s?(Up|Soon)|Next\s+(time|on|episode)|Extra|Teaser|Trailer)(?!\sEnd)(\s|:|$)";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the regular expression used to detect Recap chapters.
|
||||||
|
/// </summary>
|
||||||
|
public string ChapterAnalyzerRecapPattern { get; set; } =
|
||||||
|
@"(^|\s)(Re?cap|Sum{1,2}ary|Prev(ious(ly)?)?|(Last|Earlier)(\s\w+)?|Catch[ -]up)(?!\sEnd)(\s|:|$)";
|
||||||
|
|
||||||
|
// ===== Playback settings =====
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to show the skip intro button.
|
||||||
|
/// </summary>
|
||||||
|
public bool SkipButtonEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether to show the skip intro warning.
|
||||||
|
/// </summary>
|
||||||
|
public bool SkipButtonWarning { get => WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether plugin options are presented to the user.
|
||||||
|
/// </summary>
|
||||||
|
public bool PluginSkip { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether introductions should be automatically skipped.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoSkip { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of segment types to auto skip.
|
||||||
|
/// </summary>
|
||||||
|
public string TypeList { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether credits should be automatically skipped.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoSkipCredits { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether recap should be automatically skipped.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoSkipRecap { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether preview should be automatically skipped.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoSkipPreview { 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 RemainingSecondsOfIntro { get; set; } = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the amount of intro at start to play (in seconds).
|
||||||
|
/// </summary>
|
||||||
|
public int SecondsOfIntroStartToPlay { get; set; }
|
||||||
|
|
||||||
|
// ===== 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; } = "Segment skipped";
|
||||||
|
|
||||||
|
/// <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 number of threads for a ffmpeg process.
|
||||||
|
/// </summary>
|
||||||
|
public int ProcessThreads { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the relative priority for a ffmpeg process.
|
||||||
|
/// </summary>
|
||||||
|
public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the ManifestUrl is self-managed, e.g. for mainland China.
|
||||||
|
/// </summary>
|
||||||
|
public bool OverrideManifestUrl { get; set; }
|
||||||
|
}
|
61
IntroSkipper/Configuration/UserInterfaceConfiguration.cs
Normal file
61
IntroSkipper/Configuration/UserInterfaceConfiguration.cs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace IntroSkipper.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User interface configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="UserInterfaceConfiguration"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="visible">Skip button visibility.</param>
|
||||||
|
/// <param name="introText">Skip button intro text.</param>
|
||||||
|
/// <param name="creditsText">Skip button end credits text.</param>
|
||||||
|
/// <param name="autoSkip">Auto Skip Intro.</param>
|
||||||
|
/// <param name="autoSkipCredits">Auto Skip Credits.</param>
|
||||||
|
/// <param name="autoSkipRecap">Auto Skip Recap.</param>
|
||||||
|
/// <param name="autoSkipPreview">Auto Skip Preview.</param>
|
||||||
|
/// <param name="clientList">Auto Skip Clients.</param>
|
||||||
|
public class UserInterfaceConfiguration(bool visible, string introText, string creditsText, bool autoSkip, bool autoSkipCredits, bool autoSkipRecap, bool autoSkipPreview, string clientList)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to show the skip intro button.
|
||||||
|
/// </summary>
|
||||||
|
public bool SkipButtonVisible { get; set; } = visible;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the text to display in the skip intro button in introduction mode.
|
||||||
|
/// </summary>
|
||||||
|
public string SkipButtonIntroText { get; set; } = introText;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the text to display in the skip intro button in end credits mode.
|
||||||
|
/// </summary>
|
||||||
|
public string SkipButtonEndCreditsText { get; set; } = creditsText;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether auto skip intro.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoSkip { get; set; } = autoSkip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether auto skip credits.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoSkipCredits { get; set; } = autoSkipCredits;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether auto skip recap.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoSkipRecap { get; set; } = autoSkipRecap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether auto skip preview.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoSkipPreview { get; set; } = autoSkipPreview;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating clients to auto skip for.
|
||||||
|
/// </summary>
|
||||||
|
public string ClientList { get; set; } = clientList;
|
||||||
|
}
|
1672
IntroSkipper/Configuration/configPage.html
Normal file
1672
IntroSkipper/Configuration/configPage.html
Normal file
File diff suppressed because it is too large
Load Diff
597
IntroSkipper/Configuration/inject.js
Normal file
597
IntroSkipper/Configuration/inject.js
Normal file
@ -0,0 +1,597 @@
|
|||||||
|
const introSkipper = {
|
||||||
|
originalFetch: window.fetch.bind(window),
|
||||||
|
originalXHROpen: XMLHttpRequest.prototype.open,
|
||||||
|
d: (msg) => console.debug("[intro skipper] ", msg),
|
||||||
|
setup() {
|
||||||
|
const self = this;
|
||||||
|
this.initializeState();
|
||||||
|
this.initializeObserver();
|
||||||
|
this.currentOption =
|
||||||
|
localStorage.getItem("introskipperOption") || "Show Button";
|
||||||
|
window.fetch = this.fetchWrapper.bind(this);
|
||||||
|
XMLHttpRequest.prototype.open = function (...args) {
|
||||||
|
self.xhrOpenWrapper(this, ...args);
|
||||||
|
};
|
||||||
|
document.addEventListener("viewshow", this.viewShow.bind(this));
|
||||||
|
this.videoPositionChanged = this.videoPositionChanged.bind(this);
|
||||||
|
this.handleEscapeKey = this.handleEscapeKey.bind(this);
|
||||||
|
this.d("Registered hooks");
|
||||||
|
},
|
||||||
|
initializeState() {
|
||||||
|
Object.assign(this, {
|
||||||
|
allowEnter: true,
|
||||||
|
skipSegments: {},
|
||||||
|
videoPlayer: null,
|
||||||
|
skipButton: null,
|
||||||
|
osdElement: null,
|
||||||
|
skipperData: null,
|
||||||
|
currentEpisodeId: null,
|
||||||
|
injectMetadata: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
initializeObserver() {
|
||||||
|
this.observer = new MutationObserver((mutations) => {
|
||||||
|
const actionSheet =
|
||||||
|
mutations[mutations.length - 1].target.querySelector(".actionSheet");
|
||||||
|
if (
|
||||||
|
actionSheet &&
|
||||||
|
!actionSheet.querySelector(`[data-id="${"introskipperMenu"}"]`)
|
||||||
|
)
|
||||||
|
this.injectIntroSkipperOptions(actionSheet);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fetchWrapper(resource, options) {
|
||||||
|
const response = this.originalFetch(resource, options);
|
||||||
|
const url = new URL(resource);
|
||||||
|
if (this.injectMetadata && url.pathname.includes("/MetadataEditor")) {
|
||||||
|
this.processMetadata(url.pathname);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
xhrOpenWrapper(xhr, method, url, ...rest) {
|
||||||
|
url.includes("/PlaybackInfo") && this.processPlaybackInfo(url);
|
||||||
|
return this.originalXHROpen.apply(xhr, [method, url, ...rest]);
|
||||||
|
},
|
||||||
|
async processPlaybackInfo(url) {
|
||||||
|
const id = this.extractId(url);
|
||||||
|
if (id) {
|
||||||
|
try {
|
||||||
|
this.skipSegments = await this.secureFetch(
|
||||||
|
`Episode/${id}/IntroSkipperSegments`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.d(`Error fetching skip segments: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async processMetadata(url) {
|
||||||
|
const id = this.extractId(url);
|
||||||
|
if (id) {
|
||||||
|
try {
|
||||||
|
this.skipperData = await this.secureFetch(`Episode/${id}/Timestamps`);
|
||||||
|
if (this.skipperData) {
|
||||||
|
this.currentEpisodeId = id;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const metadataFormFields = document.querySelector(
|
||||||
|
".metadataFormFields",
|
||||||
|
);
|
||||||
|
metadataFormFields && this.injectSkipperFields(metadataFormFields);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error processing", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extractId(searchString) {
|
||||||
|
const startIndex = searchString.indexOf("Items/") + 6;
|
||||||
|
const endIndex = searchString.indexOf("/", startIndex);
|
||||||
|
return endIndex !== -1
|
||||||
|
? searchString.substring(startIndex, endIndex)
|
||||||
|
: searchString.substring(startIndex);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Event handler that runs whenever the current view changes.
|
||||||
|
* Used to detect the start of video playback.
|
||||||
|
*/
|
||||||
|
viewShow() {
|
||||||
|
const location = window.location.hash;
|
||||||
|
this.d(`Location changed to ${location}`);
|
||||||
|
this.allowEnter = true;
|
||||||
|
this.injectMetadata = /#\/(tv|details|home|search)/.test(location);
|
||||||
|
if (location === "#/video") {
|
||||||
|
this.injectCss();
|
||||||
|
this.injectButton();
|
||||||
|
this.videoPlayer = document.querySelector("video");
|
||||||
|
if (this.videoPlayer) {
|
||||||
|
this.d("Hooking video timeupdate");
|
||||||
|
this.videoPlayer.addEventListener(
|
||||||
|
"timeupdate",
|
||||||
|
this.videoPositionChanged,
|
||||||
|
);
|
||||||
|
this.osdElement = document.querySelector("div.videoOsdBottom");
|
||||||
|
this.observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Injects the CSS used by the skip intro button.
|
||||||
|
* Calling this function is a no-op if the CSS has already been injected.
|
||||||
|
*/
|
||||||
|
injectCss() {
|
||||||
|
if (document.querySelector("style#introSkipperCss")) {
|
||||||
|
this.d("CSS already added");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.d("Adding CSS");
|
||||||
|
const styleElement = document.createElement("style");
|
||||||
|
styleElement.id = "introSkipperCss";
|
||||||
|
styleElement.textContent = `
|
||||||
|
:root {
|
||||||
|
--rounding: 4px;
|
||||||
|
--accent: 0, 164, 220;
|
||||||
|
}
|
||||||
|
#skipIntro.upNextContainer {
|
||||||
|
width: unset;
|
||||||
|
margin: unset;
|
||||||
|
}
|
||||||
|
#skipIntro {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 7.5em;
|
||||||
|
right: 5em;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
#skipIntro .emby-button {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 110%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: var(--rounding);
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
|
||||||
|
transition: opacity 0.3s cubic-bezier(0.4,0,0.2,1),
|
||||||
|
transform 0.3s cubic-bezier(0.4,0,0.2,1),
|
||||||
|
background-color 0.2s ease-out,
|
||||||
|
box-shadow 0.2s ease-out;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(50%);
|
||||||
|
}
|
||||||
|
#skipIntro.show .emby-button {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
#skipIntro .emby-button:hover {
|
||||||
|
background: rgb(var(--accent));
|
||||||
|
box-shadow: 0 0 8px rgba(var(--accent), 0.6);
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
#skipIntro .emby-button:focus {
|
||||||
|
background: rgb(var(--accent));
|
||||||
|
box-shadow: 0 0 8px rgba(var(--accent), 0.6);
|
||||||
|
}
|
||||||
|
#btnSkipSegmentText {
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 0 5px 0 5px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.querySelector("head").appendChild(styleElement);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Inject the skip intro button into the video player.
|
||||||
|
* Calling this function is a no-op if the CSS has already been injected.
|
||||||
|
*/
|
||||||
|
async injectButton() {
|
||||||
|
// Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
|
||||||
|
const preExistingButton = document.querySelector("div.skipIntro");
|
||||||
|
if (preExistingButton) {
|
||||||
|
preExistingButton.style.display = "none";
|
||||||
|
}
|
||||||
|
if (document.querySelector(".btnSkipIntro.injected")) {
|
||||||
|
this.d("Button already added");
|
||||||
|
this.skipButton = document.querySelector("#skipIntro");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
|
||||||
|
if (!config.SkipButtonVisible) {
|
||||||
|
this.d("Not adding button: not visible");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.d("Adding button");
|
||||||
|
this.skipButton = document.createElement("div");
|
||||||
|
this.skipButton.id = "skipIntro";
|
||||||
|
this.skipButton.classList.add("hide", "upNextContainer");
|
||||||
|
this.skipButton.addEventListener("click", this.doSkip.bind(this));
|
||||||
|
this.skipButton.addEventListener("keydown", this.eventHandler.bind(this));
|
||||||
|
this.skipButton.innerHTML = `
|
||||||
|
<button is="emby-button" type="button" class="btnSkipIntro injected">
|
||||||
|
<span id="btnSkipSegmentText"></span>
|
||||||
|
<span class="material-icons skip_next"></span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
this.skipButton.dataset.Introduction = config.SkipButtonIntroText;
|
||||||
|
this.skipButton.dataset.Credits = config.SkipButtonEndCreditsText;
|
||||||
|
const controls = document.querySelector("div#videoOsdPage");
|
||||||
|
controls.appendChild(this.skipButton);
|
||||||
|
},
|
||||||
|
/** Tests if the OSD controls are visible. */
|
||||||
|
osdVisible() {
|
||||||
|
return this.osdElement
|
||||||
|
? !this.osdElement.classList.contains("hide")
|
||||||
|
: false;
|
||||||
|
},
|
||||||
|
/** Get the currently playing skippable segment. */
|
||||||
|
getCurrentSegment(position) {
|
||||||
|
for (const key in this.skipSegments) {
|
||||||
|
const segment = this.skipSegments[key];
|
||||||
|
if (
|
||||||
|
(position > segment.ShowSkipPromptAt &&
|
||||||
|
position < segment.HideSkipPromptAt) ||
|
||||||
|
(this.osdVisible() &&
|
||||||
|
position > segment.IntroStart &&
|
||||||
|
position < segment.IntroEnd - 3)
|
||||||
|
) {
|
||||||
|
segment["SegmentType"] = key;
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { SegmentType: "None" };
|
||||||
|
},
|
||||||
|
overrideBlur(button) {
|
||||||
|
if (!button.originalBlur) {
|
||||||
|
button.originalBlur = button.blur;
|
||||||
|
button.blur = function () {
|
||||||
|
if (!this.contains(document.activeElement)) {
|
||||||
|
this.originalBlur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Playback position changed, check if the skip button needs to be displayed. */
|
||||||
|
videoPositionChanged() {
|
||||||
|
if (!this.skipButton || !this.allowEnter) return;
|
||||||
|
const { SegmentType: segmentType } = this.getCurrentSegment(this.videoPlayer.currentTime);
|
||||||
|
if (
|
||||||
|
segmentType === "None" ||
|
||||||
|
this.currentOption === "Off"
|
||||||
|
) {
|
||||||
|
this.hideSkipButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.currentOption === "Automatically Skip" ||
|
||||||
|
(this.currentOption === "Button w/ auto PiP" &&
|
||||||
|
document.pictureInPictureElement)
|
||||||
|
) {
|
||||||
|
this.doSkip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = this.skipButton.querySelector(".emby-button");
|
||||||
|
this.skipButton.querySelector("#btnSkipSegmentText").textContent =
|
||||||
|
this.skipButton.dataset[segmentType];
|
||||||
|
if (!this.skipButton.classList.contains("hide")) {
|
||||||
|
if (!this.osdVisible() && !button.contains(document.activeElement)) {
|
||||||
|
button.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.skipButton.classList.remove("hide");
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.skipButton.classList.add("show");
|
||||||
|
this.overrideBlur(button);
|
||||||
|
button.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
hideSkipButton() {
|
||||||
|
if (this.skipButton.classList.contains("show")) {
|
||||||
|
this.skipButton.classList.remove("show");
|
||||||
|
const button = this.skipButton.querySelector(".emby-button");
|
||||||
|
button.addEventListener(
|
||||||
|
"transitionend",
|
||||||
|
() => {
|
||||||
|
this.skipButton.classList.add("hide");
|
||||||
|
if (this.osdVisible()) {
|
||||||
|
this.osdElement.querySelector("button.btnPause").focus();
|
||||||
|
} else {
|
||||||
|
button.originalBlur();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Seeks to the end of the intro. */
|
||||||
|
doSkip() {
|
||||||
|
if (!this.allowEnter) return;
|
||||||
|
const segment = this.getCurrentSegment(this.videoPlayer.currentTime);
|
||||||
|
if (segment.SegmentType === "None") {
|
||||||
|
console.warn("[intro skipper] doSkip() called without an active segment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.d(`Skipping ${segment.SegmentType}`);
|
||||||
|
const seekedHandler = () => {
|
||||||
|
this.videoPlayer.removeEventListener("seeked", seekedHandler);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.allowEnter = true;
|
||||||
|
}, 700);
|
||||||
|
};
|
||||||
|
this.videoPlayer.addEventListener("seeked", seekedHandler);
|
||||||
|
this.videoPlayer.currentTime = segment.IntroEnd;
|
||||||
|
this.hideSkipButton();
|
||||||
|
},
|
||||||
|
createButton(ref, id, innerHTML, clickHandler) {
|
||||||
|
const button = ref.cloneNode(true);
|
||||||
|
button.setAttribute("data-id", id);
|
||||||
|
button.innerHTML = innerHTML;
|
||||||
|
button.addEventListener("click", clickHandler);
|
||||||
|
return button;
|
||||||
|
},
|
||||||
|
closeSubmenu(fullscreen) {
|
||||||
|
document.querySelector(".dialogContainer").remove();
|
||||||
|
document.querySelector(".dialogBackdrop").remove();
|
||||||
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Control" }));
|
||||||
|
if (!fullscreen) return;
|
||||||
|
document.removeEventListener("keydown", this.handleEscapeKey);
|
||||||
|
document.querySelector(".btnVideoOsdSettings").focus();
|
||||||
|
},
|
||||||
|
openSubmenu(ref, menu) {
|
||||||
|
const options = [
|
||||||
|
"Show Button",
|
||||||
|
"Button w/ auto PiP",
|
||||||
|
"Automatically Skip",
|
||||||
|
"Off",
|
||||||
|
];
|
||||||
|
const submenu = menu.cloneNode(true);
|
||||||
|
const scroller = submenu.querySelector(".actionSheetScroller");
|
||||||
|
scroller.innerHTML = "";
|
||||||
|
for (const option of options) {
|
||||||
|
if (option !== "Button w/ auto PiP" || document.pictureInPictureEnabled) {
|
||||||
|
const button = this.createButton(
|
||||||
|
ref,
|
||||||
|
`introskipper-${option.toLowerCase().replace(" ", "-")}`,
|
||||||
|
`<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons check" aria-hidden="true" style="visibility:${option === this.currentOption ? "visible" : "hidden"};"></span><div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">${option}</div></div>`,
|
||||||
|
() => this.selectOption(option),
|
||||||
|
);
|
||||||
|
scroller.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "dialogBackdrop dialogBackdropOpened";
|
||||||
|
document.body.append(backdrop, submenu);
|
||||||
|
const actionSheet = submenu.querySelector(".actionSheet");
|
||||||
|
if (actionSheet.classList.contains("actionsheet-not-fullscreen")) {
|
||||||
|
this.adjustPosition(
|
||||||
|
actionSheet,
|
||||||
|
document.querySelector(".btnVideoOsdSettings"),
|
||||||
|
);
|
||||||
|
submenu.addEventListener("click", () => this.closeSubmenu(false));
|
||||||
|
} else {
|
||||||
|
submenu
|
||||||
|
.querySelector(".btnCloseActionSheet")
|
||||||
|
.addEventListener("click", () => this.closeSubmenu(true));
|
||||||
|
scroller.addEventListener("click", () => this.closeSubmenu(true));
|
||||||
|
document.addEventListener("keydown", this.handleEscapeKey);
|
||||||
|
setTimeout(() => scroller.firstElementChild.focus(), 240);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectOption(option) {
|
||||||
|
this.currentOption = option;
|
||||||
|
localStorage.setItem("introskipperOption", option);
|
||||||
|
this.d(`Introskipper option selected and saved: ${option}`);
|
||||||
|
},
|
||||||
|
isAutoSkipLocked(config) {
|
||||||
|
const isAutoSkip = config.AutoSkip && config.AutoSkipCredits;
|
||||||
|
const isAutoSkipClient = new Set(config.ClientList.split(",")).has(
|
||||||
|
ApiClient.appName(),
|
||||||
|
);
|
||||||
|
return isAutoSkip || (config.SkipButtonVisible && isAutoSkipClient);
|
||||||
|
},
|
||||||
|
async injectIntroSkipperOptions(actionSheet) {
|
||||||
|
if (!this.skipButton) return;
|
||||||
|
const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
|
||||||
|
if (this.isAutoSkipLocked(config)) {
|
||||||
|
this.d("Auto skip enforced by server");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statsButton = actionSheet.querySelector('[data-id="stats"]');
|
||||||
|
if (!statsButton) return;
|
||||||
|
const menuItem = this.createButton(
|
||||||
|
statsButton,
|
||||||
|
"introskipperMenu",
|
||||||
|
`<div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">Intro Skipper</div></div><div class="listItemAside actionSheetItemAsideText">${this.currentOption}</div>`,
|
||||||
|
() =>
|
||||||
|
this.openSubmenu(statsButton, actionSheet.closest(".dialogContainer")),
|
||||||
|
);
|
||||||
|
const originalWidth = actionSheet.offsetWidth;
|
||||||
|
statsButton.before(menuItem);
|
||||||
|
if (actionSheet.classList.contains("actionsheet-not-fullscreen"))
|
||||||
|
this.adjustPosition(actionSheet, menuItem, originalWidth);
|
||||||
|
},
|
||||||
|
adjustPosition(element, reference, originalWidth) {
|
||||||
|
if (originalWidth) {
|
||||||
|
const currentTop = Number.parseInt(element.style.top, 10) || 0;
|
||||||
|
element.style.top = `${currentTop - reference.offsetHeight}px`;
|
||||||
|
const newWidth = Math.max(reference.offsetWidth - originalWidth, 0);
|
||||||
|
const originalLeft = Number.parseInt(element.style.left, 10) || 0;
|
||||||
|
element.style.left = `${originalLeft - newWidth / 2}px`;
|
||||||
|
} else {
|
||||||
|
const rect = reference.getBoundingClientRect();
|
||||||
|
element.style.left = `${Math.min(rect.left - (element.offsetWidth - rect.width) / 2, window.innerWidth - element.offsetWidth - 10)}px`;
|
||||||
|
element.style.top = `${rect.top - element.offsetHeight + rect.height}px`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
injectSkipperFields(metadataFormFields) {
|
||||||
|
const skipperFields = document.createElement("div");
|
||||||
|
skipperFields.className = "detailSection introskipperSection";
|
||||||
|
skipperFields.innerHTML = `
|
||||||
|
<h2>Intro Skipper</h2>
|
||||||
|
<div class="inlineForm">
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label>
|
||||||
|
<input type="text" id="introStartDisplay" class="emby-input custom-time-input" readonly>
|
||||||
|
<input type="number" id="introStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label>
|
||||||
|
<input type="text" id="introEndDisplay" class="emby-input custom-time-input" readonly>
|
||||||
|
<input type="number" id="introEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inlineForm">
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
|
||||||
|
<input type="text" id="creditsStartDisplay" class="emby-input custom-time-input" readonly>
|
||||||
|
<input type="number" id="creditsStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="creditsEnd">Credits End</label>
|
||||||
|
<input type="text" id="creditsEndDisplay" class="emby-input custom-time-input" readonly>
|
||||||
|
<input type="number" id="creditsEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
metadataFormFields
|
||||||
|
.querySelector("#metadataSettingsCollapsible")
|
||||||
|
.insertAdjacentElement("afterend", skipperFields);
|
||||||
|
this.attachSaveListener(metadataFormFields);
|
||||||
|
this.updateSkipperFields(skipperFields);
|
||||||
|
this.setTimeInputs(skipperFields);
|
||||||
|
},
|
||||||
|
updateSkipperFields(skipperFields) {
|
||||||
|
const { Introduction = {}, Credits = {} } = this.skipperData;
|
||||||
|
skipperFields.querySelector("#introStartEdit").value =
|
||||||
|
Introduction.Start || 0;
|
||||||
|
skipperFields.querySelector("#introEndEdit").value = Introduction.End || 0;
|
||||||
|
skipperFields.querySelector("#creditsStartEdit").value = Credits.Start || 0;
|
||||||
|
skipperFields.querySelector("#creditsEndEdit").value = Credits.End || 0;
|
||||||
|
},
|
||||||
|
attachSaveListener(metadataFormFields) {
|
||||||
|
const saveButton = metadataFormFields.querySelector(
|
||||||
|
".formDialogFooter .btnSave",
|
||||||
|
);
|
||||||
|
if (saveButton) {
|
||||||
|
saveButton.addEventListener("click", this.saveSkipperData.bind(this));
|
||||||
|
} else {
|
||||||
|
console.error("Save button not found");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setTimeInputs(skipperFields) {
|
||||||
|
const inputContainers = skipperFields.querySelectorAll(".inputContainer");
|
||||||
|
for (const container of inputContainers) {
|
||||||
|
const displayInput = container.querySelector('[id$="Display"]');
|
||||||
|
const editInput = container.querySelector('[id$="Edit"]');
|
||||||
|
displayInput.addEventListener("pointerdown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.switchToEdit(displayInput, editInput);
|
||||||
|
});
|
||||||
|
editInput.addEventListener("blur", () =>
|
||||||
|
this.switchToDisplay(displayInput, editInput),
|
||||||
|
);
|
||||||
|
displayInput.value = this.formatTime(
|
||||||
|
Number.parseFloat(editInput.value) || 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatTime(totalSeconds) {
|
||||||
|
if (!totalSeconds) return "0 seconds";
|
||||||
|
const totalRoundedSeconds = Math.round(totalSeconds);
|
||||||
|
const hours = Math.floor(totalRoundedSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalRoundedSeconds % 3600) / 60);
|
||||||
|
const seconds = totalRoundedSeconds % 60;
|
||||||
|
const result = [];
|
||||||
|
if (hours) result.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
|
||||||
|
if (minutes) result.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
|
||||||
|
if (seconds || !result.length)
|
||||||
|
result.push(`${seconds} second${seconds !== 1 ? "s" : ""}`);
|
||||||
|
return result.join(" ");
|
||||||
|
},
|
||||||
|
switchToEdit(displayInput, editInput) {
|
||||||
|
displayInput.style.display = "none";
|
||||||
|
editInput.style.display = "";
|
||||||
|
editInput.focus();
|
||||||
|
},
|
||||||
|
switchToDisplay(displayInput, editInput) {
|
||||||
|
editInput.style.display = "none";
|
||||||
|
displayInput.style.display = "";
|
||||||
|
displayInput.value = this.formatTime(
|
||||||
|
Number.parseFloat(editInput.value) || 0,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async saveSkipperData() {
|
||||||
|
const newTimestamps = {
|
||||||
|
Introduction: {
|
||||||
|
Start: Number.parseFloat(
|
||||||
|
document.getElementById("introStartEdit").value || 0,
|
||||||
|
),
|
||||||
|
End: Number.parseFloat(
|
||||||
|
document.getElementById("introEndEdit").value || 0,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Credits: {
|
||||||
|
Start: Number.parseFloat(
|
||||||
|
document.getElementById("creditsStartEdit").value || 0,
|
||||||
|
),
|
||||||
|
End: Number.parseFloat(
|
||||||
|
document.getElementById("creditsEndEdit").value || 0,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { Introduction = {}, Credits = {} } = this.skipperData;
|
||||||
|
if (
|
||||||
|
newTimestamps.Introduction.Start !== (Introduction.Start || 0) ||
|
||||||
|
newTimestamps.Introduction.End !== (Introduction.End || 0) ||
|
||||||
|
newTimestamps.Credits.Start !== (Credits.Start || 0) ||
|
||||||
|
newTimestamps.Credits.End !== (Credits.End || 0)
|
||||||
|
) {
|
||||||
|
const response = await this.secureFetch(
|
||||||
|
`Episode/${this.currentEpisodeId}/Timestamps`,
|
||||||
|
"POST",
|
||||||
|
JSON.stringify(newTimestamps),
|
||||||
|
);
|
||||||
|
this.d(
|
||||||
|
response.ok
|
||||||
|
? "Timestamps updated successfully"
|
||||||
|
: "Failed to update timestamps:",
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.d("Timestamps have not changed, skipping update");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
|
||||||
|
async secureFetch(url, method = "GET", body = null) {
|
||||||
|
const response = await fetch(`${ApiClient.serverAddress()}/${url}`, {
|
||||||
|
method,
|
||||||
|
headers: Object.assign(
|
||||||
|
{ Authorization: `MediaBrowser Token=${ApiClient.accessToken()}` },
|
||||||
|
method === "POST" ? { "Content-Type": "application/json" } : {},
|
||||||
|
),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return response.ok
|
||||||
|
? method === "POST"
|
||||||
|
? response
|
||||||
|
: response.json()
|
||||||
|
: response.status === 404
|
||||||
|
? null
|
||||||
|
: console.error(`Error ${response.status} from ${url}`) || null;
|
||||||
|
},
|
||||||
|
/** Handle keydown events. */
|
||||||
|
eventHandler(e) {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
this.doSkip();
|
||||||
|
},
|
||||||
|
handleEscapeKey(e) {
|
||||||
|
if (e.key === "Escape" || e.keyCode === 461 || e.keyCode === 10009) {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.closeSubmenu(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
introSkipper.setup();
|
234
IntroSkipper/Configuration/visualizer.js
Normal file
234
IntroSkipper/Configuration/visualizer.js
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
// 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.1238);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.1238;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
274
IntroSkipper/Controllers/SkipIntroController.cs
Normal file
274
IntroSkipper/Controllers/SkipIntroController.cs
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Mime;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Configuration;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using IntroSkipper.Db;
|
||||||
|
using IntroSkipper.Manager;
|
||||||
|
using MediaBrowser.Common.Api;
|
||||||
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Skip intro controller.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Produces(MediaTypeNames.Application.Json)]
|
||||||
|
public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateManager) : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
||||||
|
|
||||||
|
/// <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 intros = GetIntros(id);
|
||||||
|
if (!intros.TryGetValue(mode, out var intro))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return intro;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the timestamps for the provided episode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Episode ID to update timestamps for.</param>
|
||||||
|
/// <param name="timestamps">New timestamps Introduction/Credits start and end times.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation Token.</param>
|
||||||
|
/// <response code="204">New timestamps saved.</response>
|
||||||
|
/// <response code="404">Given ID is not an Episode.</response>
|
||||||
|
/// <returns>No content.</returns>
|
||||||
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
|
[HttpPost("Episode/{Id}/Timestamps")]
|
||||||
|
public async Task<ActionResult> UpdateTimestampsAsync([FromRoute] Guid id, [FromBody] TimeStamps timestamps, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// only update existing episodes
|
||||||
|
var rawItem = Plugin.Instance!.GetItem(id);
|
||||||
|
if (rawItem is not Episode and not Movie)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamps == null)
|
||||||
|
{
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
var segmentTypes = new[]
|
||||||
|
{
|
||||||
|
(AnalysisMode.Introduction, timestamps.Introduction),
|
||||||
|
(AnalysisMode.Credits, timestamps.Credits),
|
||||||
|
(AnalysisMode.Recap, timestamps.Recap),
|
||||||
|
(AnalysisMode.Preview, timestamps.Preview)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (mode, segment) in segmentTypes)
|
||||||
|
{
|
||||||
|
if (segment.Valid)
|
||||||
|
{
|
||||||
|
await Plugin.Instance!.UpdateTimestampAsync(segment, mode).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Plugin.Instance.Configuration.UpdateMediaSegments)
|
||||||
|
{
|
||||||
|
var episode = Plugin.Instance!.QueuedMediaItems[rawItem is Episode e ? e.SeasonId : rawItem.Id]
|
||||||
|
.FirstOrDefault(q => q.EpisodeId == rawItem.Id);
|
||||||
|
|
||||||
|
if (episode is not null)
|
||||||
|
{
|
||||||
|
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync([episode], cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the timestamps for the provided episode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Episode ID.</param>
|
||||||
|
/// <response code="200">Sucess.</response>
|
||||||
|
/// <response code="404">Given ID is not an Episode.</response>
|
||||||
|
/// <returns>Episode Timestamps.</returns>
|
||||||
|
[HttpGet("Episode/{Id}/Timestamps")]
|
||||||
|
[ActionName("UpdateTimestamps")]
|
||||||
|
public ActionResult<TimeStamps> GetTimestamps([FromRoute] Guid id)
|
||||||
|
{
|
||||||
|
// only get return content for episodes
|
||||||
|
var rawItem = Plugin.Instance!.GetItem(id);
|
||||||
|
if (rawItem is not Episode and not Movie)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var times = new TimeStamps();
|
||||||
|
var segments = Plugin.Instance!.GetTimestamps(id);
|
||||||
|
|
||||||
|
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
|
||||||
|
{
|
||||||
|
times.Introduction = introSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment))
|
||||||
|
{
|
||||||
|
times.Credits = creditSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.TryGetValue(AnalysisMode.Recap, out var recapSegment))
|
||||||
|
{
|
||||||
|
times.Recap = recapSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.TryGetValue(AnalysisMode.Preview, out var previewSegment))
|
||||||
|
{
|
||||||
|
times.Preview = previewSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return times;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 = GetIntros(id);
|
||||||
|
var result = new Dictionary<AnalysisMode, Intro>();
|
||||||
|
|
||||||
|
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
|
||||||
|
{
|
||||||
|
result[AnalysisMode.Introduction] = introSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment))
|
||||||
|
{
|
||||||
|
result[AnalysisMode.Credits] = creditSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lookup and return the skippable timestamps for the provided item.</summary>
|
||||||
|
/// <param name="id">Unique identifier of this episode.</param>
|
||||||
|
/// <returns>Intro object if the provided item has an intro, null otherwise.</returns>
|
||||||
|
internal static Dictionary<AnalysisMode, Intro> GetIntros(Guid id)
|
||||||
|
{
|
||||||
|
var timestamps = Plugin.Instance!.GetTimestamps(id);
|
||||||
|
var intros = new Dictionary<AnalysisMode, Intro>();
|
||||||
|
var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds;
|
||||||
|
var config = Plugin.Instance.Configuration;
|
||||||
|
|
||||||
|
foreach (var (mode, timestamp) in timestamps)
|
||||||
|
{
|
||||||
|
if (!timestamp.Valid)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new Intro to avoid mutating the original stored in dictionary
|
||||||
|
var segment = new Intro(timestamp);
|
||||||
|
|
||||||
|
// Calculate intro end time
|
||||||
|
segment.IntroEnd = runTime > 0 && runTime < segment.IntroEnd + 1
|
||||||
|
? runTime
|
||||||
|
: segment.IntroEnd - config.RemainingSecondsOfIntro;
|
||||||
|
|
||||||
|
// Set skip button prompt visibility times
|
||||||
|
const double MIN_REMAINING_TIME = 3.0; // Minimum seconds before end to hide prompt
|
||||||
|
if (config.PersistSkipButton)
|
||||||
|
{
|
||||||
|
segment.ShowSkipPromptAt = segment.IntroStart;
|
||||||
|
segment.HideSkipPromptAt = segment.IntroEnd - MIN_REMAINING_TIME;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
|
||||||
|
segment.HideSkipPromptAt = Math.Min(
|
||||||
|
segment.IntroStart + config.HidePromptAdjustment,
|
||||||
|
segment.IntroEnd - MIN_REMAINING_TIME);
|
||||||
|
}
|
||||||
|
|
||||||
|
intros[mode] = segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return intros;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 async Task<ActionResult> ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false)
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(Plugin.Instance!.DbPath);
|
||||||
|
var segments = await db.DbSegment
|
||||||
|
.Where(s => s.Type == mode)
|
||||||
|
.ToListAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
db.DbSegment.RemoveRange(segments);
|
||||||
|
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (eraseCache && mode is AnalysisMode.Introduction or AnalysisMode.Credits)
|
||||||
|
{
|
||||||
|
await Task.Run(() => FFmpegWrapper.DeleteCacheFiles(mode)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.SkipButtonEnabled,
|
||||||
|
config.SkipButtonIntroText,
|
||||||
|
config.SkipButtonEndCreditsText,
|
||||||
|
config.AutoSkip,
|
||||||
|
config.AutoSkipCredits,
|
||||||
|
config.AutoSkipRecap,
|
||||||
|
config.AutoSkipPreview,
|
||||||
|
config.ClientList);
|
||||||
|
}
|
||||||
|
}
|
156
IntroSkipper/Controllers/TroubleshootingController.cs
Normal file
156
IntroSkipper/Controllers/TroubleshootingController.cs
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Mime;
|
||||||
|
using System.Text;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using IntroSkipper.Helper;
|
||||||
|
using MediaBrowser.Common;
|
||||||
|
using MediaBrowser.Common.Api;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Troubleshooting controller.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
|
[ApiController]
|
||||||
|
[Produces(MediaTypeNames.Application.Json)]
|
||||||
|
[Route("IntroSkipper")]
|
||||||
|
public class TroubleshootingController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
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="libraryManager">Library Manager.</param>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public TroubleshootingController(
|
||||||
|
IApplicationHost applicationHost,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
ILogger<TroubleshootingController> logger)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_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 = Commit.CommitHash;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a Markdown formatted support bundle.
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200">Support bundle created.</response>
|
||||||
|
/// <returns>Support bundle.</returns>
|
||||||
|
[HttpGet("Storage")]
|
||||||
|
[Produces(MediaTypeNames.Text.Plain)]
|
||||||
|
public ActionResult<string> GetFreeSpace()
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(Plugin.Instance);
|
||||||
|
var bundle = new StringBuilder();
|
||||||
|
|
||||||
|
var libraries = _libraryManager.GetVirtualFolders();
|
||||||
|
foreach (var library in libraries)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DriveInfo driveInfo = new DriveInfo(library.Locations[0]);
|
||||||
|
// Get available free space in bytes
|
||||||
|
long availableFreeSpace = driveInfo.AvailableFreeSpace;
|
||||||
|
|
||||||
|
// Get total size of the drive in bytes
|
||||||
|
long totalSize = driveInfo.TotalSize;
|
||||||
|
|
||||||
|
// Get total used space in Percentage
|
||||||
|
double usedSpacePercentage = totalSize > 0 ? (totalSize - availableFreeSpace) / (double)totalSize * 100 : 0;
|
||||||
|
|
||||||
|
bundle.Append(CultureInfo.CurrentCulture, $"Library: {library.Name}\n");
|
||||||
|
bundle.Append(CultureInfo.CurrentCulture, $"Drive: {driveInfo.Name}\n");
|
||||||
|
bundle.Append(CultureInfo.CurrentCulture, $"Total Size: {GetHumanReadableSize(totalSize)}\n");
|
||||||
|
bundle.Append(CultureInfo.CurrentCulture, $"Available Free Space: {GetHumanReadableSize(availableFreeSpace)}\n");
|
||||||
|
bundle.Append(CultureInfo.CurrentCulture, $"Total used in Percentage: {Math.Round(usedSpacePercentage, 2)}%\n\n");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unable to get DriveInfo: {Exception}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle.ToString().TrimEnd('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetHumanReadableSize(long bytes)
|
||||||
|
{
|
||||||
|
string[] sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||||
|
double len = bytes;
|
||||||
|
int order = 0;
|
||||||
|
|
||||||
|
while (len >= 1024 && order < sizes.Length - 1)
|
||||||
|
{
|
||||||
|
order++;
|
||||||
|
len /= 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{len:0.##} {sizes[order]}";
|
||||||
|
}
|
||||||
|
}
|
249
IntroSkipper/Controllers/VisualizationController.cs
Normal file
249
IntroSkipper/Controllers/VisualizationController.cs
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Mime;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using IntroSkipper.Db;
|
||||||
|
using IntroSkipper.Manager;
|
||||||
|
using MediaBrowser.Common.Api;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="VisualizationController"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
/// <param name="mediaSegmentUpdateManager">Media Segment Update Manager.</param>
|
||||||
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
|
[ApiController]
|
||||||
|
[Produces(MediaTypeNames.Application.Json)]
|
||||||
|
[Route("Intros")]
|
||||||
|
public class VisualizationController(ILogger<VisualizationController> logger, MediaSegmentUpdateManager mediaSegmentUpdateManager) : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<VisualizationController> _logger = logger;
|
||||||
|
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
||||||
|
|
||||||
|
/// <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<Guid, ShowInfos>> GetShowSeasons()
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Returning season IDs by series name");
|
||||||
|
|
||||||
|
var showSeasons = new Dictionary<Guid, ShowInfos>();
|
||||||
|
|
||||||
|
foreach (var kvp in Plugin.Instance!.QueuedMediaItems)
|
||||||
|
{
|
||||||
|
if (kvp.Value.FirstOrDefault() is QueuedEpisode first)
|
||||||
|
{
|
||||||
|
var seriesId = first.SeriesId;
|
||||||
|
var seasonId = kvp.Key;
|
||||||
|
|
||||||
|
var seasonNumber = first.SeasonNumber;
|
||||||
|
if (!showSeasons.TryGetValue(seriesId, out var showInfo))
|
||||||
|
{
|
||||||
|
showInfo = new ShowInfos { SeriesName = first.SeriesName, ProductionYear = GetProductionYear(seriesId), LibraryName = GetLibraryName(seriesId), IsMovie = first.IsMovie, Seasons = [] };
|
||||||
|
showSeasons[seriesId] = showInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
showInfo.Seasons[seasonId] = seasonNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the dictionary by SeriesName and the seasons by SeasonName
|
||||||
|
var sortedShowSeasons = showSeasons
|
||||||
|
.OrderBy(kvp => kvp.Value.SeriesName)
|
||||||
|
.ToDictionary(
|
||||||
|
kvp => kvp.Key,
|
||||||
|
kvp => new ShowInfos
|
||||||
|
{
|
||||||
|
SeriesName = kvp.Value.SeriesName,
|
||||||
|
ProductionYear = kvp.Value.ProductionYear,
|
||||||
|
LibraryName = kvp.Value.LibraryName,
|
||||||
|
IsMovie = kvp.Value.IsMovie,
|
||||||
|
Seasons = kvp.Value.Seasons
|
||||||
|
.OrderBy(s => s.Value)
|
||||||
|
.ToDictionary(s => s.Key, s => s.Value)
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedShowSeasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the analyzer actions for the provided season.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seasonId">Season ID.</param>
|
||||||
|
/// <returns>List of episode titles.</returns>
|
||||||
|
[HttpGet("AnalyzerActions/{SeasonId}")]
|
||||||
|
public ActionResult<IReadOnlyDictionary<AnalysisMode, AnalyzerAction>> GetAnalyzerAction([FromRoute] Guid seasonId)
|
||||||
|
{
|
||||||
|
if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(seasonId))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var analyzerActions = new Dictionary<AnalysisMode, AnalyzerAction>();
|
||||||
|
foreach (var mode in Enum.GetValues<AnalysisMode>())
|
||||||
|
{
|
||||||
|
analyzerActions[mode] = Plugin.Instance!.GetAnalyzerAction(seasonId, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(analyzerActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the names and unique identifiers of all episodes in the provided season.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seriesId">Show ID.</param>
|
||||||
|
/// <param name="seasonId">Season ID.</param>
|
||||||
|
/// <returns>List of episode titles.</returns>
|
||||||
|
[HttpGet("Show/{SeriesId}/{SeasonId}")]
|
||||||
|
public ActionResult<List<EpisodeVisualization>> GetSeasonEpisodes([FromRoute] Guid seriesId, [FromRoute] Guid seasonId)
|
||||||
|
{
|
||||||
|
if (!Plugin.Instance!.QueuedMediaItems.TryGetValue(seasonId, out var episodes))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!episodes.Any(e => e.SeriesId == seriesId))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var showName = episodes.FirstOrDefault()?.SeriesName!;
|
||||||
|
|
||||||
|
return episodes.Select(e => new EpisodeVisualization(e.EpisodeId, e.Name)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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="seriesId">Show ID.</param>
|
||||||
|
/// <param name="seasonId">Season ID.</param>
|
||||||
|
/// <param name="eraseCache">Erase cache.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation Token.</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/{SeriesId}/{SeasonId}")]
|
||||||
|
public async Task<ActionResult> EraseSeasonAsync([FromRoute] Guid seriesId, [FromRoute] Guid seasonId, [FromQuery] bool eraseCache = false, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var episodes = Plugin.Instance!.QueuedMediaItems[seasonId];
|
||||||
|
|
||||||
|
if (episodes.Count == 0)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Erasing timestamps for series {SeriesId} season {SeasonId} at user request", seriesId, seasonId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(Plugin.Instance!.DbPath);
|
||||||
|
|
||||||
|
foreach (var episode in episodes)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var existingSegments = db.DbSegment.Where(s => s.ItemId == episode.EpisodeId);
|
||||||
|
|
||||||
|
db.DbSegment.RemoveRange(existingSegments);
|
||||||
|
|
||||||
|
if (eraseCache)
|
||||||
|
{
|
||||||
|
await Task.Run(() => FFmpegWrapper.DeleteEpisodeCache(episode.EpisodeId), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var seasonInfo = db.DbSeasonInfo.Where(s => s.SeasonId == seasonId);
|
||||||
|
|
||||||
|
foreach (var info in seasonInfo)
|
||||||
|
{
|
||||||
|
db.Entry(info).Property(s => s.EpisodeIds).CurrentValue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (Plugin.Instance.Configuration.UpdateMediaSegments)
|
||||||
|
{
|
||||||
|
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the analyzer actions for the provided season.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Update analyzer actions request.</param>
|
||||||
|
/// <returns>No content.</returns>
|
||||||
|
[HttpPost("AnalyzerActions/UpdateSeason")]
|
||||||
|
public async Task<ActionResult> UpdateAnalyzerActions([FromBody] UpdateAnalyzerActionsRequest request)
|
||||||
|
{
|
||||||
|
await Plugin.Instance!.SetAnalyzerActionAsync(request.Id, request.AnalyzerActions).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetProductionYear(Guid seriesId)
|
||||||
|
{
|
||||||
|
return seriesId == Guid.Empty
|
||||||
|
? "Unknown"
|
||||||
|
: Plugin.Instance?.GetItem(seriesId)?.ProductionYear?.ToString(CultureInfo.InvariantCulture) ?? "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetLibraryName(Guid seriesId)
|
||||||
|
{
|
||||||
|
if (seriesId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
var collectionFolders = Plugin.Instance?.GetCollectionFolders(seriesId);
|
||||||
|
return collectionFolders?.Count > 0
|
||||||
|
? string.Join(", ", collectionFolders.Select(folder => folder.Name))
|
||||||
|
: "Unknown";
|
||||||
|
}
|
||||||
|
}
|
30
IntroSkipper/Data/AnalysisMode.cs
Normal file
30
IntroSkipper/Data/AnalysisMode.cs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Type of media file analysis to perform.
|
||||||
|
/// </summary>
|
||||||
|
public enum AnalysisMode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Detect introduction sequences.
|
||||||
|
/// </summary>
|
||||||
|
Introduction,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detect credits.
|
||||||
|
/// </summary>
|
||||||
|
Credits,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detect previews.
|
||||||
|
/// </summary>
|
||||||
|
Preview,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detect recaps.
|
||||||
|
/// </summary>
|
||||||
|
Recap,
|
||||||
|
}
|
35
IntroSkipper/Data/AnalyzerAction.cs
Normal file
35
IntroSkipper/Data/AnalyzerAction.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Type of media file analysis to perform.
|
||||||
|
/// </summary>
|
||||||
|
public enum AnalyzerAction
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Default action.
|
||||||
|
/// </summary>
|
||||||
|
Default,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detect chapters.
|
||||||
|
/// </summary>
|
||||||
|
Chapter,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detect chromaprint fingerprints.
|
||||||
|
/// </summary>
|
||||||
|
Chromaprint,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detect black frames.
|
||||||
|
/// </summary>
|
||||||
|
BlackFrame,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No action.
|
||||||
|
/// </summary>
|
||||||
|
None,
|
||||||
|
}
|
25
IntroSkipper/Data/BlackFrame.cs
Normal file
25
IntroSkipper/Data/BlackFrame.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A frame of video that partially (or entirely) consists of black pixels.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="BlackFrame"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="percent">Percentage of the frame that is black.</param>
|
||||||
|
/// <param name="time">Time this frame appears at.</param>
|
||||||
|
public class BlackFrame(int percent, double time)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the percentage of the frame that is black.
|
||||||
|
/// </summary>
|
||||||
|
public int Percentage { get; set; } = percent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the time (in seconds) this frame appeared at.
|
||||||
|
/// </summary>
|
||||||
|
public double Time { get; set; } = time;
|
||||||
|
}
|
27
IntroSkipper/Data/EpisodeVisualization.cs
Normal file
27
IntroSkipper/Data/EpisodeVisualization.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Episode name and internal ID as returned by the visualization controller.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="EpisodeVisualization"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="id">Episode id.</param>
|
||||||
|
/// <param name="name">Episode name.</param>
|
||||||
|
public class EpisodeVisualization(Guid id, string name)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; private set; } = id;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; private set; } = name;
|
||||||
|
}
|
36
IntroSkipper/Data/FingerprintException.cs
Normal file
36
IntroSkipper/Data/FingerprintException.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
53
IntroSkipper/Data/Intro.cs
Normal file
53
IntroSkipper/Data/Intro.cs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of fingerprinting and analyzing two episodes in a season.
|
||||||
|
/// All times are measured in seconds relative to the beginning of the media file.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="Intro"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="intro">intro.</param>
|
||||||
|
[DataContract(Namespace = "http://schemas.datacontract.org/2004/07/ConfusedPolarBear.Plugin.IntroSkipper")]
|
||||||
|
public class Intro(Segment intro)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Episode ID.
|
||||||
|
/// </summary>
|
||||||
|
[DataMember]
|
||||||
|
public Guid EpisodeId { get; set; } = intro.EpisodeId;
|
||||||
|
|
||||||
|
/// <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 or sets the introduction sequence start time.
|
||||||
|
/// </summary>
|
||||||
|
[DataMember]
|
||||||
|
public double IntroStart { get; set; } = intro.Start;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the introduction sequence end time.
|
||||||
|
/// </summary>
|
||||||
|
[DataMember]
|
||||||
|
public double IntroEnd { get; set; } = intro.End;
|
||||||
|
|
||||||
|
/// <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; }
|
||||||
|
}
|
33
IntroSkipper/Data/PluginWarning.cs
Normal file
33
IntroSkipper/Data/PluginWarning.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
/// <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,
|
||||||
|
}
|
77
IntroSkipper/Data/QueuedEpisode.cs
Normal file
77
IntroSkipper/Data/QueuedEpisode.cs
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
/// <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 season id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid SeasonId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the series id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid SeriesId { 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 a value indicating whether an episode is Anime.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAnime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether an item is a movie.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsMovie { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether an episode has been analyzed.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAnalyzed { get; set; }
|
||||||
|
|
||||||
|
/// <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; }
|
||||||
|
}
|
98
IntroSkipper/Data/Segment.cs
Normal file
98
IntroSkipper/Data/Segment.cs
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of fingerprinting and analyzing two episodes in a season.
|
||||||
|
/// All times are measured in seconds relative to the beginning of the media file.
|
||||||
|
/// </summary>
|
||||||
|
[DataContract(Namespace = "http://schemas.datacontract.org/2004/07/ConfusedPolarBear.Plugin.IntroSkipper.Segment")]
|
||||||
|
public class Segment
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Segment"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="episode">Episode.</param>
|
||||||
|
/// <param name="segment">Introduction time range.</param>
|
||||||
|
public Segment(Guid episode, TimeRange segment)
|
||||||
|
{
|
||||||
|
EpisodeId = episode;
|
||||||
|
Start = segment.Start;
|
||||||
|
End = segment.End;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Segment"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="episode">Episode.</param>
|
||||||
|
public Segment(Guid episode)
|
||||||
|
{
|
||||||
|
EpisodeId = episode;
|
||||||
|
Start = 0.0;
|
||||||
|
End = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Segment"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="intro">intro.</param>
|
||||||
|
public Segment(Segment intro)
|
||||||
|
{
|
||||||
|
EpisodeId = intro.EpisodeId;
|
||||||
|
Start = intro.Start;
|
||||||
|
End = intro.End;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Segment"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="intro">intro.</param>
|
||||||
|
public Segment(Intro intro)
|
||||||
|
{
|
||||||
|
EpisodeId = intro.EpisodeId;
|
||||||
|
Start = intro.IntroStart;
|
||||||
|
End = intro.IntroEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Segment"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public Segment()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Episode ID.
|
||||||
|
/// </summary>
|
||||||
|
[DataMember]
|
||||||
|
public Guid EpisodeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the introduction sequence start time.
|
||||||
|
/// </summary>
|
||||||
|
[DataMember]
|
||||||
|
public double Start { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the introduction sequence end time.
|
||||||
|
/// </summary>
|
||||||
|
[DataMember]
|
||||||
|
public double End { 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 => End > 0.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the duration of this intro.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public double Duration => End - Start;
|
||||||
|
}
|
39
IntroSkipper/Data/ShowInfos.cs
Normal file
39
IntroSkipper/Data/ShowInfos.cs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Contains information about a show.
|
||||||
|
/// </summary>
|
||||||
|
public class ShowInfos
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Name of the show.
|
||||||
|
/// </summary>
|
||||||
|
public required string SeriesName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Year of the show.
|
||||||
|
/// </summary>
|
||||||
|
public required string ProductionYear { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Library of the show.
|
||||||
|
/// </summary>
|
||||||
|
public required string LibraryName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether its a movie.
|
||||||
|
/// </summary>
|
||||||
|
public required bool IsMovie { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Seasons of the show.
|
||||||
|
/// </summary>
|
||||||
|
public required Dictionary<Guid, int> Seasons { get; init; }
|
||||||
|
}
|
||||||
|
}
|
86
IntroSkipper/Data/TimeRange.cs
Normal file
86
IntroSkipper/Data/TimeRange.cs
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
#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 not 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);
|
||||||
|
}
|
||||||
|
}
|
53
IntroSkipper/Data/TimeRangeHelpers.cs
Normal file
53
IntroSkipper/Data/TimeRangeHelpers.cs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
}
|
||||||
|
}
|
32
IntroSkipper/Data/TimeStamps.cs
Normal file
32
IntroSkipper/Data/TimeStamps.cs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Result of fingerprinting and analyzing two episodes in a season.
|
||||||
|
/// All times are measured in seconds relative to the beginning of the media file.
|
||||||
|
/// </summary>
|
||||||
|
public class TimeStamps
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Introduction.
|
||||||
|
/// </summary>
|
||||||
|
public Segment Introduction { get; set; } = new Segment();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Credits.
|
||||||
|
/// </summary>
|
||||||
|
public Segment Credits { get; set; } = new Segment();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Recap.
|
||||||
|
/// </summary>
|
||||||
|
public Segment Recap { get; set; } = new Segment();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets Preview.
|
||||||
|
/// </summary>
|
||||||
|
public Segment Preview { get; set; } = new Segment();
|
||||||
|
}
|
||||||
|
}
|
21
IntroSkipper/Data/UpdateAnalyzerActionsRequest.cs
Normal file
21
IntroSkipper/Data/UpdateAnalyzerActionsRequest.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// /// Update analyzer actions request.
|
||||||
|
/// </summary>
|
||||||
|
public class UpdateAnalyzerActionsRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets season ID.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets analyzer actions.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<AnalysisMode, AnalyzerAction> AnalyzerActions { get; set; } = new Dictionary<AnalysisMode, AnalyzerAction>();
|
||||||
|
}
|
||||||
|
}
|
48
IntroSkipper/Data/WarningManager.cs
Normal file
48
IntroSkipper/Data/WarningManager.cs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace IntroSkipper.Data;
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if a specific warning flag is set.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="warning">Warning flag to check.</param>
|
||||||
|
/// <returns>True if the flag is set, otherwise false.</returns>
|
||||||
|
public static bool HasFlag(PluginWarning warning)
|
||||||
|
{
|
||||||
|
return (_warnings & warning) == warning;
|
||||||
|
}
|
||||||
|
}
|
59
IntroSkipper/Db/DbSeasonInfo.cs
Normal file
59
IntroSkipper/Db/DbSeasonInfo.cs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Db;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All times are measured in seconds relative to the beginning of the media file.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="DbSeasonInfo"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
public class DbSeasonInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DbSeasonInfo"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seasonId">Season ID.</param>
|
||||||
|
/// <param name="mode">Analysis mode.</param>
|
||||||
|
/// <param name="action">Analyzer action.</param>
|
||||||
|
/// <param name="episodeIds">Episode IDs.</param>
|
||||||
|
public DbSeasonInfo(Guid seasonId, AnalysisMode mode, AnalyzerAction action, IEnumerable<Guid>? episodeIds = null)
|
||||||
|
{
|
||||||
|
SeasonId = seasonId;
|
||||||
|
Type = mode;
|
||||||
|
Action = action;
|
||||||
|
EpisodeIds = episodeIds ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DbSeasonInfo"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public DbSeasonInfo()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the item ID.
|
||||||
|
/// </summary>
|
||||||
|
public Guid SeasonId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the analysis mode.
|
||||||
|
/// </summary>
|
||||||
|
public AnalysisMode Type { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the analyzer action.
|
||||||
|
/// </summary>
|
||||||
|
public AnalyzerAction Action { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the season number.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<Guid> EpisodeIds { get; private set; } = [];
|
||||||
|
}
|
65
IntroSkipper/Db/DbSegment.cs
Normal file
65
IntroSkipper/Db/DbSegment.cs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Db;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All times are measured in seconds relative to the beginning of the media file.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="DbSegment"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
public class DbSegment
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DbSegment"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="segment">The segment to initialize the instance with.</param>
|
||||||
|
/// <param name="type">The type of analysis that was used to determine this segment.</param>
|
||||||
|
public DbSegment(Segment segment, AnalysisMode type)
|
||||||
|
{
|
||||||
|
ItemId = segment.EpisodeId;
|
||||||
|
Start = segment.Start;
|
||||||
|
End = segment.End;
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DbSegment"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public DbSegment()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the episode id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid ItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the start time.
|
||||||
|
/// </summary>
|
||||||
|
public double Start { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the end time.
|
||||||
|
/// </summary>
|
||||||
|
public double End { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the type of analysis that was used to determine this segment.
|
||||||
|
/// </summary>
|
||||||
|
public AnalysisMode Type { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts the instance to a <see cref="Segment"/> object.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A <see cref="Segment"/> object.</returns>
|
||||||
|
internal Segment ToSegment()
|
||||||
|
{
|
||||||
|
return new Segment(ItemId, new TimeRange(Start, End));
|
||||||
|
}
|
||||||
|
}
|
147
IntroSkipper/Db/IntroSkipperDbContext.cs
Normal file
147
IntroSkipper/Db/IntroSkipperDbContext.cs
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Db;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin database.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="IntroSkipperDbContext"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
public class IntroSkipperDbContext : DbContext
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="IntroSkipperDbContext"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dbPath">The path to the SQLite database file.</param>
|
||||||
|
public IntroSkipperDbContext(string dbPath)
|
||||||
|
{
|
||||||
|
_dbPath = dbPath;
|
||||||
|
DbSegment = Set<DbSegment>();
|
||||||
|
DbSeasonInfo = Set<DbSeasonInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="IntroSkipperDbContext"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The options.</param>
|
||||||
|
public IntroSkipperDbContext(DbContextOptions<IntroSkipperDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
var folder = Environment.SpecialFolder.LocalApplicationData;
|
||||||
|
var path = Environment.GetFolderPath(folder);
|
||||||
|
_dbPath = System.IO.Path.Join(path, "introskipper.db");
|
||||||
|
DbSegment = Set<DbSegment>();
|
||||||
|
DbSeasonInfo = Set<DbSeasonInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the <see cref="DbSet{TEntity}"/> containing the segments.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<DbSegment> DbSegment { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the <see cref="DbSet{TEntity}"/> containing the season information.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<DbSeasonInfo> DbSeasonInfo { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
optionsBuilder.UseSqlite($"Data Source={_dbPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<DbSegment>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("DbSegment");
|
||||||
|
entity.HasKey(s => new { s.ItemId, s.Type });
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.ItemId);
|
||||||
|
|
||||||
|
entity.Property(e => e.Start)
|
||||||
|
.HasDefaultValue(0.0)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
entity.Property(e => e.End)
|
||||||
|
.HasDefaultValue(0.0)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<DbSeasonInfo>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("DbSeasonInfo");
|
||||||
|
entity.HasKey(s => new { s.SeasonId, s.Type });
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.SeasonId);
|
||||||
|
|
||||||
|
entity.Property(e => e.Action)
|
||||||
|
.HasDefaultValue(AnalyzerAction.Default)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
entity.Property(e => e.EpisodeIds)
|
||||||
|
.HasConversion(
|
||||||
|
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
||||||
|
v => JsonSerializer.Deserialize<IEnumerable<Guid>>(v, (JsonSerializerOptions?)null) ?? new List<Guid>(),
|
||||||
|
new ValueComparer<IEnumerable<Guid>>(
|
||||||
|
(c1, c2) => (c1 ?? new List<Guid>()).SequenceEqual(c2 ?? new List<Guid>()),
|
||||||
|
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||||
|
c => c.ToList()));
|
||||||
|
});
|
||||||
|
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies any pending migrations to the database.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyMigrations()
|
||||||
|
{
|
||||||
|
// If migrations table exists, just apply pending migrations normally
|
||||||
|
if (Database.GetAppliedMigrations().Any() || !Database.CanConnect())
|
||||||
|
{
|
||||||
|
Database.Migrate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For databases without migration history
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Backup existing data
|
||||||
|
List<DbSegment> segments;
|
||||||
|
using (var db = new IntroSkipperDbContext(_dbPath))
|
||||||
|
{
|
||||||
|
segments = [.. db.DbSegment.AsEnumerable().Where(s => s.ToSegment().Valid)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old database
|
||||||
|
Database.EnsureDeleted();
|
||||||
|
|
||||||
|
// Create new database with proper migration history
|
||||||
|
Database.Migrate();
|
||||||
|
|
||||||
|
// Restore the data
|
||||||
|
using (var db = new IntroSkipperDbContext(_dbPath))
|
||||||
|
{
|
||||||
|
db.DbSegment.AddRange(segments);
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to apply migrations", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
20
IntroSkipper/Db/IntroSkipperDbContextFactory.cs
Normal file
20
IntroSkipper/Db/IntroSkipperDbContextFactory.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Db;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IntroSkipperDbContext factory.
|
||||||
|
/// </summary>
|
||||||
|
public class IntroSkipperDbContextFactory : IDesignTimeDbContextFactory<IntroSkipperDbContext>
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public IntroSkipperDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<IntroSkipperDbContext>();
|
||||||
|
optionsBuilder.UseSqlite("Data Source=introskipper.db")
|
||||||
|
.EnableSensitiveDataLogging(false);
|
||||||
|
|
||||||
|
return new IntroSkipperDbContext(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
672
IntroSkipper/FFmpegWrapper.cs
Normal file
672
IntroSkipper/FFmpegWrapper.cs
Normal file
@ -0,0 +1,672 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wrapper for libchromaprint and the silencedetect filter.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class FFmpegWrapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Regex _silenceDetectionExpression = SilenceRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used with FFmpeg's blackframe filter to extract the time and percentage of black pixels.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Regex _blackFrameRegex = BlackFrameRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the logger.
|
||||||
|
/// </summary>
|
||||||
|
public static ILogger? Logger { get; set; }
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ChromaprintLogs { get; set; } = [];
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// Detect ranges of silence in the provided episode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="episode">Queued episode.</param>
|
||||||
|
/// <param name="range">Time range to search.</param>
|
||||||
|
/// <returns>Array of TimeRange objects that are silent in the queued episode.</returns>
|
||||||
|
public static TimeRange[] DetectSilence(QueuedEpisode episode, TimeRange range)
|
||||||
|
{
|
||||||
|
Logger?.LogTrace(
|
||||||
|
"Detecting silence in \"{File}\" (range {Start}-{End}, id {Id})",
|
||||||
|
episode.Path,
|
||||||
|
range.Start,
|
||||||
|
range.End,
|
||||||
|
episode.EpisodeId);
|
||||||
|
|
||||||
|
// -vn, -sn, -dn: ignore video, subtitle, and data tracks
|
||||||
|
var args = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"-vn -sn -dn " +
|
||||||
|
"-ss {0} -i \"{1}\" -to {2} -af \"silencedetect=noise={3}dB:duration=0.1\" -f null -",
|
||||||
|
range.Start,
|
||||||
|
episode.Path,
|
||||||
|
range.End - range.Start,
|
||||||
|
Plugin.Instance?.Configuration.SilenceDetectionMaximumNoise ?? -50);
|
||||||
|
|
||||||
|
// Cache the output of this command to "GUID-intro-silence-v2"
|
||||||
|
var cacheKey = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}-silence-{1}-{2}-v2",
|
||||||
|
episode.EpisodeId.ToString("N"),
|
||||||
|
range.Start,
|
||||||
|
range.End);
|
||||||
|
|
||||||
|
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 + range.Start;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
currentRange.End = time + range.Start;
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
using 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 var ms = new MemoryStream();
|
||||||
|
var buf = new byte[4096];
|
||||||
|
int bytesRead;
|
||||||
|
|
||||||
|
using (var streamReader = stderr ? ffmpeg.StandardError : ffmpeg.StandardOutput)
|
||||||
|
{
|
||||||
|
while ((bytesRead = streamReader.BaseStream.Read(buf, 0, buf.Length)) > 0)
|
||||||
|
{
|
||||||
|
ms.Write(buf, 0, bytesRead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg.WaitForExit(timeout);
|
||||||
|
|
||||||
|
var output = ms.ToArray();
|
||||||
|
|
||||||
|
// If caching is enabled, cache the output of this command.
|
||||||
|
if (cacheOutput)
|
||||||
|
{
|
||||||
|
File.WriteAllBytes(cacheFilename, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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))
|
||||||
|
{
|
||||||
|
Logger?.LogDebug("DeleteEpisodeCache {FilePath}", filePath);
|
||||||
|
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>
|
||||||
|
/// 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>
|
||||||
|
/// <returns>Path.</returns>
|
||||||
|
public 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex("silence_(?<type>start|end): (?<time>[0-9\\.]+)")]
|
||||||
|
private static partial Regex SilenceRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex("(pblack|t):[0-9.]+")]
|
||||||
|
private static partial Regex BlackFrameRegex();
|
||||||
|
}
|
16
IntroSkipper/Helper/Commit.cs
Normal file
16
IntroSkipper/Helper/Commit.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
namespace IntroSkipper.Helper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the commit used to build the plugin.
|
||||||
|
/// </summary>
|
||||||
|
public static class Commit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the commit hash used to build the plugin.
|
||||||
|
/// </summary>
|
||||||
|
public static string CommitHash => string.Empty;
|
||||||
|
}
|
||||||
|
}
|
218
IntroSkipper/Helper/LegacyMigrations.cs
Normal file
218
IntroSkipper/Helper/LegacyMigrations.cs
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Serialization;
|
||||||
|
using IntroSkipper.Configuration;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using IntroSkipper.Db;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Model.Updates;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Helper;
|
||||||
|
|
||||||
|
internal static class LegacyMigrations
|
||||||
|
{
|
||||||
|
public static void MigrateAll(
|
||||||
|
Plugin plugin,
|
||||||
|
IServerConfigurationManager serverConfiguration,
|
||||||
|
ILogger logger,
|
||||||
|
IApplicationPaths applicationPaths)
|
||||||
|
{
|
||||||
|
var pluginDirName = "introskipper";
|
||||||
|
var introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml");
|
||||||
|
var creditsPath = Path.Join(applicationPaths.DataPath, pluginDirName, "credits.xml");
|
||||||
|
// Migrate XML files from XMLSchema to DataContract
|
||||||
|
XmlSerializationHelper.MigrateXML(introPath);
|
||||||
|
XmlSerializationHelper.MigrateXML(creditsPath);
|
||||||
|
|
||||||
|
MigrateConfig(plugin, applicationPaths.PluginConfigurationsPath, logger);
|
||||||
|
MigrateRepoUrl(plugin, serverConfiguration, logger);
|
||||||
|
InjectSkipButton(plugin, applicationPaths.WebPath, logger);
|
||||||
|
RestoreTimestamps(plugin.DbPath, introPath, creditsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MigrateConfig(Plugin plugin, string pluginConfigurationsPath, ILogger logger)
|
||||||
|
{
|
||||||
|
var oldConfigFile = Path.Join(pluginConfigurationsPath, "ConfusedPolarBear.Plugin.IntroSkipper.xml");
|
||||||
|
|
||||||
|
if (File.Exists(oldConfigFile))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
XmlSerializer serializer = new XmlSerializer(typeof(PluginConfiguration));
|
||||||
|
using FileStream fileStream = new FileStream(oldConfigFile, FileMode.Open);
|
||||||
|
var settings = new XmlReaderSettings
|
||||||
|
{
|
||||||
|
DtdProcessing = DtdProcessing.Prohibit, // Disable DTD processing
|
||||||
|
XmlResolver = null // Disable the XmlResolver
|
||||||
|
};
|
||||||
|
|
||||||
|
using var reader = XmlReader.Create(fileStream, settings);
|
||||||
|
if (serializer.Deserialize(reader) is PluginConfiguration oldConfig)
|
||||||
|
{
|
||||||
|
plugin.UpdateConfiguration(oldConfig);
|
||||||
|
fileStream.Close();
|
||||||
|
File.Delete(oldConfigFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Handle exceptions, such as file not found, deserialization errors, etc.
|
||||||
|
logger.LogWarning("Failed to migrate from the ConfusedPolarBear Config {Exception}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MigrateRepoUrl(Plugin plugin, IServerConfigurationManager serverConfiguration, ILogger logger)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<string> oldRepos =
|
||||||
|
[
|
||||||
|
"https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/manifest.json",
|
||||||
|
"https://raw.githubusercontent.com/jumoog/intro-skipper/master/manifest.json",
|
||||||
|
"https://manifest.intro-skipper.workers.dev/manifest.json"
|
||||||
|
];
|
||||||
|
|
||||||
|
var config = serverConfiguration.Configuration;
|
||||||
|
var pluginRepositories = config.PluginRepositories.ToList();
|
||||||
|
|
||||||
|
if (pluginRepositories.Exists(repo => repo.Url != null && oldRepos.Contains(repo.Url)))
|
||||||
|
{
|
||||||
|
pluginRepositories.RemoveAll(repo => repo.Url != null && oldRepos.Contains(repo.Url));
|
||||||
|
|
||||||
|
if (!pluginRepositories.Exists(repo => repo.Url == "https://manifest.intro-skipper.org/manifest.json") && plugin.Configuration.OverrideManifestUrl)
|
||||||
|
{
|
||||||
|
pluginRepositories.Add(new RepositoryInfo
|
||||||
|
{
|
||||||
|
Name = "intro skipper (automatically migrated by plugin)",
|
||||||
|
Url = "https://manifest.intro-skipper.org/manifest.json",
|
||||||
|
Enabled = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
config.PluginRepositories = [.. pluginRepositories];
|
||||||
|
serverConfiguration.SaveConfiguration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error occurred while migrating repo URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InjectSkipButton(Plugin plugin, string webPath, ILogger logger)
|
||||||
|
{
|
||||||
|
string pattern = @"<script src=""configurationpage\?name=skip-intro-button\.js.*<\/script>";
|
||||||
|
string indexPath = Path.Join(webPath, "index.html");
|
||||||
|
|
||||||
|
// Check if we can actually access the file
|
||||||
|
bool canAccessFile = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(indexPath))
|
||||||
|
{
|
||||||
|
using var fs = File.Open(indexPath, FileMode.Open, FileAccess.ReadWrite);
|
||||||
|
canAccessFile = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// If skip button is disabled and we can't access the file, just return silently
|
||||||
|
if (!plugin.Configuration.SkipButtonEnabled)
|
||||||
|
{
|
||||||
|
logger.LogDebug("Skip button disabled and no permission to access index.html. Assuming its a fresh install.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
|
||||||
|
logger.LogError("Failed to add skip button to web interface. See https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible for the most common issues.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canAccessFile)
|
||||||
|
{
|
||||||
|
logger.LogDebug("Jellyfin running as nowebclient");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logger.LogInformation("Reading index.html from {Path}", indexPath);
|
||||||
|
string contents = File.ReadAllText(indexPath);
|
||||||
|
|
||||||
|
if (!plugin.Configuration.SkipButtonEnabled)
|
||||||
|
{
|
||||||
|
if (!Regex.IsMatch(contents, pattern, RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
logger.LogDebug("Skip button not found. Assuming its a fresh install.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Skip button found. Removing the Skip button.");
|
||||||
|
contents = Regex.Replace(contents, pattern, string.Empty, RegexOptions.IgnoreCase);
|
||||||
|
File.WriteAllText(indexPath, contents);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js&release=" + plugin.GetType().Assembly.GetName().Version + "\"></script>";
|
||||||
|
if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
logger.LogDebug("The skip button has already been injected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contents = Regex.Replace(contents, pattern, string.Empty, RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
Regex headEnd = new Regex(@"</head>", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
|
||||||
|
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
|
||||||
|
|
||||||
|
File.WriteAllText(indexPath, contents);
|
||||||
|
logger.LogInformation("Skip button added successfully.");
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
|
||||||
|
logger.LogError("Failed to add skip button to web interface. See https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible for the most common issues.");
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
|
||||||
|
logger.LogError("Failed to add skip button to web interface. See https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible for the most common issues.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RestoreTimestamps(string dbPath, string introPath, string creditsPath)
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(dbPath);
|
||||||
|
// Import intros
|
||||||
|
if (File.Exists(introPath))
|
||||||
|
{
|
||||||
|
var introList = XmlSerializationHelper.DeserializeFromXml<Segment>(introPath);
|
||||||
|
foreach (var intro in introList)
|
||||||
|
{
|
||||||
|
db.DbSegment.Add(new DbSegment(intro, AnalysisMode.Introduction));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import credits
|
||||||
|
if (File.Exists(creditsPath))
|
||||||
|
{
|
||||||
|
var creditList = XmlSerializationHelper.DeserializeFromXml<Segment>(creditsPath);
|
||||||
|
foreach (var credit in creditList)
|
||||||
|
{
|
||||||
|
db.DbSegment.Add(new DbSegment(credit, AnalysisMode.Credits));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
File.Delete(introPath);
|
||||||
|
File.Delete(creditsPath);
|
||||||
|
}
|
||||||
|
}
|
42
IntroSkipper/Helper/OperatingSystem.cs
Normal file
42
IntroSkipper/Helper/OperatingSystem.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Helper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provides methods to determine the operating system.
|
||||||
|
/// </summary>
|
||||||
|
public static class OperatingSystem
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if the current operating system is Windows.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>True if the current operating system is Windows; otherwise, false.</returns>
|
||||||
|
public static bool IsWindows() =>
|
||||||
|
RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if the current operating system is macOS.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>True if the current operating system is macOS; otherwise, false.</returns>
|
||||||
|
public static bool IsMacOS() =>
|
||||||
|
RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if the current operating system is Linux.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>True if the current operating system is Linux; otherwise, false.</returns>
|
||||||
|
public static bool IsLinux() =>
|
||||||
|
RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if the current environment is running in Docker.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>True if running in a Docker container; otherwise, false.</returns>
|
||||||
|
public static bool IsDocker() =>
|
||||||
|
File.Exists("/.dockerenv") || File.Exists("/run/.containerenv");
|
||||||
|
}
|
||||||
|
}
|
114
IntroSkipper/Helper/XmlSerializationHelper.cs
Normal file
114
IntroSkipper/Helper/XmlSerializationHelper.cs
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using System.Xml;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Helper
|
||||||
|
{
|
||||||
|
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 void MigrateFromIntro(string filePath)
|
||||||
|
{
|
||||||
|
List<Intro> intros = DeserializeFromXml<Intro>(filePath);
|
||||||
|
ArgumentNullException.ThrowIfNull(intros);
|
||||||
|
|
||||||
|
var segments = intros.Select(name => new Segment(name)).ToList();
|
||||||
|
|
||||||
|
SerializeToXml(segments, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<T> DeserializeFromXml<T>(string filePath)
|
||||||
|
{
|
||||||
|
var result = new List<T>();
|
||||||
|
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 List<T>
|
||||||
|
DataContractSerializer serializer = new DataContractSerializer(typeof(List<T>));
|
||||||
|
|
||||||
|
// Deserialize the object from the XML
|
||||||
|
result = serializer.ReadObject(reader) as List<T>;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// undo namespace change
|
||||||
|
if (xmlDoc.DocumentElement.NamespaceURI == "http://schemas.datacontract.org/2004/07/ConfusedPolarBear.Plugin.IntroSkipper.Data")
|
||||||
|
{
|
||||||
|
xmlDoc.DocumentElement.SetAttribute("xmlns", "http://schemas.datacontract.org/2004/07/ConfusedPolarBear.Plugin.IntroSkipper");
|
||||||
|
// Save the modified XML document
|
||||||
|
xmlDoc.Save(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// intro -> segment migration
|
||||||
|
if (xmlDoc.DocumentElement.NamespaceURI == "http://schemas.datacontract.org/2004/07/ConfusedPolarBear.Plugin.IntroSkipper")
|
||||||
|
{
|
||||||
|
MigrateFromIntro(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (XmlException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error deserializing XML: {ex.Message}");
|
||||||
|
File.Delete(filePath);
|
||||||
|
Console.WriteLine($"Deleting {filePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
IntroSkipper/IntroSkipper.csproj
Normal file
28
IntroSkipper/IntroSkipper.csproj
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>IntroSkipper</RootNamespace>
|
||||||
|
<AssemblyVersion>1.10.10.11</AssemblyVersion>
|
||||||
|
<FileVersion>1.10.10.11</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.10.*-*" />
|
||||||
|
<PackageReference Include="Jellyfin.Model" Version="10.10.*-*" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
||||||
|
<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" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
25
IntroSkipper/IntroSkipper.sln
Normal file
25
IntroSkipper/IntroSkipper.sln
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.002.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntroSkipper", "IntroSkipper.csproj", "{BF8E8662-3409-439D-95BA-FC918FFBBDB4}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{BF8E8662-3409-439D-95BA-FC918FFBBDB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{BF8E8662-3409-439D-95BA-FC918FFBBDB4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{BF8E8662-3409-439D-95BA-FC918FFBBDB4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{BF8E8662-3409-439D-95BA-FC918FFBBDB4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {8BD9D646-8C5E-41FA-8C7A-72749524B7D7}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
68
IntroSkipper/Manager/MediaSegmentUpdateManager.cs
Normal file
68
IntroSkipper/Manager/MediaSegmentUpdateManager.cs
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Model;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Manager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="MediaSegmentUpdateManager" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mediaSegmentManager">MediaSegmentManager.</param>
|
||||||
|
/// <param name="logger">logger.</param>
|
||||||
|
/// <param name="segmentProvider">segmentProvider.</param>
|
||||||
|
public class MediaSegmentUpdateManager(IMediaSegmentManager mediaSegmentManager, ILogger<MediaSegmentUpdateManager> logger, IMediaSegmentProvider segmentProvider)
|
||||||
|
{
|
||||||
|
private readonly IMediaSegmentManager _mediaSegmentManager = mediaSegmentManager;
|
||||||
|
private readonly ILogger<MediaSegmentUpdateManager> _logger = logger;
|
||||||
|
private readonly IMediaSegmentProvider _segmentProvider = segmentProvider;
|
||||||
|
private readonly string _id = Plugin.Instance!.Name.ToLowerInvariant()
|
||||||
|
.GetMD5()
|
||||||
|
.ToString("N", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates all media items in a List.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="episodes">Queued media items.</param>
|
||||||
|
/// <param name="cancellationToken">CancellationToken.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
public async Task UpdateMediaSegmentsAsync(IReadOnlyList<QueuedEpisode> episodes, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var episode in episodes)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingSegments = await _mediaSegmentManager.GetSegmentsAsync(episode.EpisodeId, null, false).ConfigureAwait(false);
|
||||||
|
await Task.WhenAll(existingSegments.Select(s => _mediaSegmentManager.DeleteSegmentAsync(s.Id))).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var newSegments = await _segmentProvider.GetMediaSegments(new MediaSegmentGenerationRequest { ItemId = episode.EpisodeId }, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (newSegments.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No segments found for episode {EpisodeId}", episode.EpisodeId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(newSegments.Select(s => _mediaSegmentManager.CreateSegmentAsync(s, _id))).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogDebug("Updated {SegmentCount} segments for episode {EpisodeId}", newSegments.Count, episode.EpisodeId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing episode {EpisodeId}", episode.EpisodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
338
IntroSkipper/Manager/QueueManager.cs
Normal file
338
IntroSkipper/Manager/QueueManager.cs
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Manager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Manages enqueuing library items for analysis.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="QueueManager"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
/// <param name="libraryManager">Library manager.</param>
|
||||||
|
public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryManager)
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager = libraryManager;
|
||||||
|
private readonly ILogger<QueueManager> _logger = logger;
|
||||||
|
private readonly Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes = [];
|
||||||
|
private double _analysisPercent;
|
||||||
|
private List<string> _selectedLibraries = [];
|
||||||
|
private bool _selectAllLibraries;
|
||||||
|
private bool _analyzeMovies;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all media items on the server.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Queued media items.</returns>
|
||||||
|
public IReadOnlyDictionary<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 (!_selectAllLibraries && !_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);
|
||||||
|
|
||||||
|
// Some virtual folders don't have a proper item id.
|
||||||
|
if (!Guid.TryParse(folder.ItemId, out var folderId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
QueueLibraryContents(folderId);
|
||||||
|
}
|
||||||
|
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 _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;
|
||||||
|
|
||||||
|
_selectAllLibraries = config.SelectAllLibraries;
|
||||||
|
|
||||||
|
_analyzeMovies = config.AnalyzeMovies;
|
||||||
|
|
||||||
|
if (!_selectAllLibraries)
|
||||||
|
{
|
||||||
|
// Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries.
|
||||||
|
_selectedLibraries = [.. config.SelectedLibraries.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||||
|
|
||||||
|
// If any libraries have been selected for analysis, log their names.
|
||||||
|
_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 = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),],
|
||||||
|
IncludeItemTypes = [BaseItemKind.Episode, BaseItemKind.Movie],
|
||||||
|
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 Episode episode)
|
||||||
|
{
|
||||||
|
QueueEpisode(episode);
|
||||||
|
}
|
||||||
|
else if (item is Movie movie)
|
||||||
|
{
|
||||||
|
if (_analyzeMovies)
|
||||||
|
{
|
||||||
|
QueueMovie(movie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Item {Name} is not an episode or movie", item.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Queued {Count} episodes", items.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueEpisode(Episode episode)
|
||||||
|
{
|
||||||
|
var pluginInstance = Plugin.Instance ?? 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
|
||||||
|
var seasonId = GetSeasonId(episode);
|
||||||
|
if (!_queuedEpisodes.TryGetValue(seasonId, out var seasonEpisodes))
|
||||||
|
{
|
||||||
|
seasonEpisodes = [];
|
||||||
|
_queuedEpisodes[seasonId] = seasonEpisodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonEpisodes.Any(e => e.EpisodeId == episode.Id))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"\"{Name}\" from series \"{Series}\" ({Id}) is already queued",
|
||||||
|
episode.Name,
|
||||||
|
episode.SeriesName,
|
||||||
|
episode.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAnime = seasonEpisodes.FirstOrDefault()?.IsAnime ??
|
||||||
|
(pluginInstance.GetItem(episode.SeriesId) is Series series &&
|
||||||
|
(series.Tags.Contains("anime", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
series.Genres.Contains("anime", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
|
// 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 = Math.Min(
|
||||||
|
duration >= 5 * 60 ? duration * _analysisPercent : duration,
|
||||||
|
60 * pluginInstance.Configuration.AnalysisLengthLimit);
|
||||||
|
|
||||||
|
var maxCreditsDuration = Math.Min(
|
||||||
|
duration >= 5 * 60 ? duration * _analysisPercent : duration,
|
||||||
|
60 * pluginInstance.Configuration.MaximumCreditsDuration);
|
||||||
|
|
||||||
|
// Queue the episode for analysis
|
||||||
|
seasonEpisodes.Add(new QueuedEpisode
|
||||||
|
{
|
||||||
|
SeriesName = episode.SeriesName,
|
||||||
|
SeasonNumber = episode.AiredSeasonNumber ?? 0,
|
||||||
|
SeriesId = episode.SeriesId,
|
||||||
|
SeasonId = episode.SeasonId,
|
||||||
|
EpisodeId = episode.Id,
|
||||||
|
Name = episode.Name,
|
||||||
|
IsAnime = isAnime,
|
||||||
|
Path = episode.Path,
|
||||||
|
Duration = Convert.ToInt32(duration),
|
||||||
|
IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration),
|
||||||
|
CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration),
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginInstance.TotalQueued++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueMovie(Movie movie)
|
||||||
|
{
|
||||||
|
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(movie.Path))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Not queuing movie \"{Name}\" ({Id}) as no path was provided by Jellyfin",
|
||||||
|
movie.Name,
|
||||||
|
movie.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate a new list for each Movie
|
||||||
|
_queuedEpisodes.TryAdd(movie.Id, []);
|
||||||
|
|
||||||
|
var duration = TimeSpan.FromTicks(movie.RunTimeTicks ?? 0).TotalSeconds;
|
||||||
|
|
||||||
|
_queuedEpisodes[movie.Id].Add(new QueuedEpisode
|
||||||
|
{
|
||||||
|
SeriesName = movie.Name,
|
||||||
|
SeriesId = movie.Id,
|
||||||
|
SeasonId = movie.Id,
|
||||||
|
EpisodeId = movie.Id,
|
||||||
|
Name = movie.Name,
|
||||||
|
Path = movie.Path,
|
||||||
|
Duration = Convert.ToInt32(duration),
|
||||||
|
CreditsFingerprintStart = Convert.ToInt32(duration - pluginInstance.Configuration.MaximumMovieCreditsDuration),
|
||||||
|
IsMovie = true
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginInstance.TotalQueued++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Guid GetSeasonId(Episode episode)
|
||||||
|
{
|
||||||
|
if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special
|
||||||
|
{
|
||||||
|
foreach (var kvp in _queuedEpisodes)
|
||||||
|
{
|
||||||
|
var first = kvp.Value.FirstOrDefault();
|
||||||
|
if (first?.SeriesId == episode.SeriesId &&
|
||||||
|
first.SeasonNumber == episode.AiredSeasonNumber)
|
||||||
|
{
|
||||||
|
return kvp.Key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return episode.SeasonId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
internal (IReadOnlyList<QueuedEpisode> QueuedEpisodes, IReadOnlyCollection<AnalysisMode> RequiredModes)
|
||||||
|
VerifyQueue(IReadOnlyList<QueuedEpisode> candidates, IReadOnlyCollection<AnalysisMode> modes)
|
||||||
|
{
|
||||||
|
var verified = new List<QueuedEpisode>();
|
||||||
|
var requiredModes = new HashSet<AnalysisMode>();
|
||||||
|
|
||||||
|
var episodeIds = Plugin.Instance!.GetEpisodeIds(candidates[0].SeasonId);
|
||||||
|
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
verified.Add(candidate);
|
||||||
|
|
||||||
|
foreach (var mode in modes)
|
||||||
|
{
|
||||||
|
if (!episodeIds.TryGetValue(mode, out var ids) || !ids.Contains(candidate.EpisodeId) || Plugin.Instance!.AnalyzeAgain)
|
||||||
|
{
|
||||||
|
requiredModes.Add(mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping analysis of {Name} ({Id}): {Exception}",
|
||||||
|
candidate.Name,
|
||||||
|
candidate.EpisodeId,
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (verified, requiredModes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
73
IntroSkipper/Migrations/20241116153434_InitialCreate.Designer.cs
generated
Normal file
73
IntroSkipper/Migrations/20241116153434_InitialCreate.Designer.cs
generated
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using IntroSkipper.Db;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace IntroSkipper.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(IntroSkipperDbContext))]
|
||||||
|
[Migration("20241116153434_InitialCreate")]
|
||||||
|
partial class InitialCreate
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("IntroSkipper.Db.DbSeasonInfo", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SeasonId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Action")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<string>("EpisodeIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeasonId", "Type");
|
||||||
|
|
||||||
|
b.HasIndex("SeasonId");
|
||||||
|
|
||||||
|
b.ToTable("DbSeasonInfo", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("IntroSkipper.Db.DbSegment", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("ItemId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<double>("End")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("REAL")
|
||||||
|
.HasDefaultValue(0.0);
|
||||||
|
|
||||||
|
b.Property<double>("Start")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("REAL")
|
||||||
|
.HasDefaultValue(0.0);
|
||||||
|
|
||||||
|
b.HasKey("ItemId", "Type");
|
||||||
|
|
||||||
|
b.HasIndex("ItemId");
|
||||||
|
|
||||||
|
b.ToTable("DbSegment", (string)null);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
63
IntroSkipper/Migrations/20241116153434_InitialCreate.cs
Normal file
63
IntroSkipper/Migrations/20241116153434_InitialCreate.cs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace IntroSkipper.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DbSeasonInfo",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SeasonId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Action = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
|
||||||
|
EpisodeIds = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DbSeasonInfo", x => new { x.SeasonId, x.Type });
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DbSegment",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ItemId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Start = table.Column<double>(type: "REAL", nullable: false, defaultValue: 0.0),
|
||||||
|
End = table.Column<double>(type: "REAL", nullable: false, defaultValue: 0.0)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DbSegment", x => new { x.ItemId, x.Type });
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DbSeasonInfo_SeasonId",
|
||||||
|
table: "DbSeasonInfo",
|
||||||
|
column: "SeasonId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DbSegment_ItemId",
|
||||||
|
table: "DbSegment",
|
||||||
|
column: "ItemId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DbSeasonInfo");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DbSegment");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using IntroSkipper.Db;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace IntroSkipper.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(IntroSkipperDbContext))]
|
||||||
|
partial class IntroSkipperDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("IntroSkipper.Db.DbSeasonInfo", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SeasonId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Action")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<string>("EpisodeIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeasonId", "Type");
|
||||||
|
|
||||||
|
b.HasIndex("SeasonId");
|
||||||
|
|
||||||
|
b.ToTable("DbSeasonInfo", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("IntroSkipper.Db.DbSegment", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("ItemId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<double>("End")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("REAL")
|
||||||
|
.HasDefaultValue(0.0);
|
||||||
|
|
||||||
|
b.Property<double>("Start")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("REAL")
|
||||||
|
.HasDefaultValue(0.0);
|
||||||
|
|
||||||
|
b.HasKey("ItemId", "Type");
|
||||||
|
|
||||||
|
b.HasIndex("ItemId");
|
||||||
|
|
||||||
|
b.ToTable("DbSegment", (string)null);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
326
IntroSkipper/Plugin.cs
Normal file
326
IntroSkipper/Plugin.cs
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Configuration;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using IntroSkipper.Db;
|
||||||
|
using IntroSkipper.Helper;
|
||||||
|
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.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace 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 ILibraryManager _libraryManager;
|
||||||
|
private readonly IItemRepository _itemRepository;
|
||||||
|
private readonly ILogger<Plugin> _logger;
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
|
||||||
|
_dbPath = Path.Join(applicationPaths.DataPath, pluginDirName, "introskipper.db");
|
||||||
|
|
||||||
|
// Create the base & cache directories (if needed).
|
||||||
|
if (!Directory.Exists(FingerprintCachePath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(FingerprintCachePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database, restore timestamps if available.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(_dbPath);
|
||||||
|
db.ApplyMigrations();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Error initializing database: {Exception}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LegacyMigrations.MigrateAll(this, serverConfiguration, logger, applicationPaths);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to perform migrations. Error: {Error}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
FFmpegWrapper.CheckFFmpegVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the path to the database.
|
||||||
|
/// </summary>
|
||||||
|
public string DbPath => _dbPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to analyze again.
|
||||||
|
/// </summary>
|
||||||
|
public bool AnalyzeAgain { get; set; }
|
||||||
|
|
||||||
|
/// <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; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<PluginPageInfo> GetPages()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new PluginPageInfo
|
||||||
|
{
|
||||||
|
Name = 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"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal BaseItem? GetItem(Guid id)
|
||||||
|
{
|
||||||
|
return id != Guid.Empty ? _libraryManager.GetItemById(id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal ICollection<Folder> GetCollectionFolders(Guid id)
|
||||||
|
{
|
||||||
|
var item = GetItem(id);
|
||||||
|
return item is not null ? _libraryManager.GetCollectionFolders(item) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 IReadOnlyList<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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return _itemRepository.GetChapters(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task UpdateTimestampAsync(Segment segment, AnalysisMode mode)
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(_dbPath);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existing = await db.DbSegment
|
||||||
|
.FirstOrDefaultAsync(s => s.ItemId == segment.EpisodeId && s.Type == mode)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var dbSegment = new DbSegment(segment, mode);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
db.Entry(existing).CurrentValues.SetValues(dbSegment);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
db.DbSegment.Add(dbSegment);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to update timestamp for episode {EpisodeId}", segment.EpisodeId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IReadOnlyDictionary<AnalysisMode, Segment> GetTimestamps(Guid id)
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(_dbPath);
|
||||||
|
return db.DbSegment.Where(s => s.ItemId == id)
|
||||||
|
.ToDictionary(s => s.Type, s => s.ToSegment());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task CleanTimestamps(IEnumerable<Guid> episodeIds)
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(_dbPath);
|
||||||
|
db.DbSegment.RemoveRange(db.DbSegment
|
||||||
|
.Where(s => !episodeIds.Contains(s.ItemId)));
|
||||||
|
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task SetAnalyzerActionAsync(Guid id, IReadOnlyDictionary<AnalysisMode, AnalyzerAction> analyzerActions)
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(_dbPath);
|
||||||
|
var existingEntries = await db.DbSeasonInfo
|
||||||
|
.Where(s => s.SeasonId == id)
|
||||||
|
.ToDictionaryAsync(s => s.Type)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var (mode, action) in analyzerActions)
|
||||||
|
{
|
||||||
|
if (existingEntries.TryGetValue(mode, out var existing))
|
||||||
|
{
|
||||||
|
db.Entry(existing).Property(s => s.Action).CurrentValue = action;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
db.DbSeasonInfo.Add(new DbSeasonInfo(id, mode, action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task SetEpisodeIdsAsync(Guid id, AnalysisMode mode, IEnumerable<Guid> episodeIds)
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(_dbPath);
|
||||||
|
var seasonInfo = db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode);
|
||||||
|
|
||||||
|
if (seasonInfo is null)
|
||||||
|
{
|
||||||
|
seasonInfo = new DbSeasonInfo(id, mode, AnalyzerAction.Default, episodeIds);
|
||||||
|
db.DbSeasonInfo.Add(seasonInfo);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
db.Entry(seasonInfo).Property(s => s.EpisodeIds).CurrentValue = episodeIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IReadOnlyDictionary<AnalysisMode, IEnumerable<Guid>> GetEpisodeIds(Guid id)
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(_dbPath);
|
||||||
|
return db.DbSeasonInfo.Where(s => s.SeasonId == id)
|
||||||
|
.ToDictionary(s => s.Type, s => s.EpisodeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal AnalyzerAction GetAnalyzerAction(Guid id, AnalysisMode mode)
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(_dbPath);
|
||||||
|
return db.DbSeasonInfo.FirstOrDefault(s => s.SeasonId == id && s.Type == mode)?.Action ?? AnalyzerAction.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task CleanSeasonInfoAsync(IEnumerable<Guid> ids)
|
||||||
|
{
|
||||||
|
using var db = new IntroSkipperDbContext(_dbPath);
|
||||||
|
var obsoleteSeasons = await db.DbSeasonInfo
|
||||||
|
.Where(s => !ids.Contains(s.SeasonId))
|
||||||
|
.ToListAsync().ConfigureAwait(false);
|
||||||
|
db.DbSeasonInfo.RemoveRange(obsoleteSeasons);
|
||||||
|
await db.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
27
IntroSkipper/PluginServiceRegistrator.cs
Normal file
27
IntroSkipper/PluginServiceRegistrator.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using IntroSkipper.Manager;
|
||||||
|
using IntroSkipper.Providers;
|
||||||
|
using IntroSkipper.Services;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace IntroSkipper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Register Intro Skipper services.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||||
|
{
|
||||||
|
serviceCollection.AddHostedService<AutoSkip>();
|
||||||
|
serviceCollection.AddHostedService<Entrypoint>();
|
||||||
|
serviceCollection.AddSingleton<IMediaSegmentProvider, SegmentProvider>();
|
||||||
|
serviceCollection.AddSingleton<MediaSegmentUpdateManager>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
90
IntroSkipper/Providers/SegmentProvider.cs
Normal file
90
IntroSkipper/Providers/SegmentProvider.cs
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Model;
|
||||||
|
using MediaBrowser.Model.MediaSegments;
|
||||||
|
|
||||||
|
namespace IntroSkipper.Providers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Introskipper media segment provider.
|
||||||
|
/// </summary>
|
||||||
|
public class SegmentProvider : IMediaSegmentProvider
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string Name => Plugin.Instance!.Name;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task<IReadOnlyList<MediaSegmentDto>> GetMediaSegments(MediaSegmentGenerationRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ArgumentNullException.ThrowIfNull(Plugin.Instance);
|
||||||
|
|
||||||
|
var segments = new List<MediaSegmentDto>();
|
||||||
|
var remainingTicks = Plugin.Instance.Configuration.RemainingSecondsOfIntro * TimeSpan.TicksPerSecond;
|
||||||
|
var itemSegments = Plugin.Instance.GetTimestamps(request.ItemId);
|
||||||
|
var runTimeTicks = Plugin.Instance.GetItem(request.ItemId)?.RunTimeTicks ?? 0;
|
||||||
|
|
||||||
|
// Define mappings between AnalysisMode and MediaSegmentType
|
||||||
|
var segmentMappings = new List<(AnalysisMode Mode, MediaSegmentType Type)>
|
||||||
|
{
|
||||||
|
(AnalysisMode.Introduction, MediaSegmentType.Intro),
|
||||||
|
(AnalysisMode.Recap, MediaSegmentType.Recap),
|
||||||
|
(AnalysisMode.Preview, MediaSegmentType.Preview),
|
||||||
|
(AnalysisMode.Credits, MediaSegmentType.Outro)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (mode, type) in segmentMappings)
|
||||||
|
{
|
||||||
|
if (itemSegments.TryGetValue(mode, out var segment) && segment.Valid)
|
||||||
|
{
|
||||||
|
long startTicks = (long)(segment.Start * TimeSpan.TicksPerSecond);
|
||||||
|
long endTicks = CalculateEndTicks(mode, segment, runTimeTicks, remainingTicks);
|
||||||
|
|
||||||
|
segments.Add(new MediaSegmentDto
|
||||||
|
{
|
||||||
|
StartTicks = startTicks,
|
||||||
|
EndTicks = endTicks,
|
||||||
|
ItemId = request.ItemId,
|
||||||
|
Type = type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<MediaSegmentDto>>(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the end ticks based on the segment type and runtime.
|
||||||
|
/// </summary>
|
||||||
|
private static long CalculateEndTicks(AnalysisMode mode, Segment segment, long runTimeTicks, long remainingTicks)
|
||||||
|
{
|
||||||
|
long endTicks = (long)(segment.End * TimeSpan.TicksPerSecond);
|
||||||
|
|
||||||
|
if (mode is AnalysisMode.Preview or AnalysisMode.Credits)
|
||||||
|
{
|
||||||
|
if (runTimeTicks > 0 && runTimeTicks < endTicks + TimeSpan.TicksPerSecond)
|
||||||
|
{
|
||||||
|
return Math.Max(runTimeTicks, endTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
return endTicks - remainingTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return endTicks - remainingTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask<bool> Supports(BaseItem item) => ValueTask.FromResult(item is Episode or Movie);
|
||||||
|
}
|
||||||
|
}
|
230
IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs
Normal file
230
IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Analyzers;
|
||||||
|
using IntroSkipper.Configuration;
|
||||||
|
using IntroSkipper.Data;
|
||||||
|
using IntroSkipper.Db;
|
||||||
|
using IntroSkipper.Manager;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper.ScheduledTasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Common code shared by all media item analyzer tasks.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="BaseItemAnalyzerTask"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="logger">Task logger.</param>
|
||||||
|
/// <param name="loggerFactory">Logger factory.</param>
|
||||||
|
/// <param name="libraryManager">Library manager.</param>
|
||||||
|
/// <param name="mediaSegmentUpdateManager">MediaSegmentUpdateManager.</param>
|
||||||
|
public class BaseItemAnalyzerTask(
|
||||||
|
ILogger logger,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
MediaSegmentUpdateManager mediaSegmentUpdateManager)
|
||||||
|
{
|
||||||
|
private readonly ILogger _logger = logger;
|
||||||
|
private readonly ILoggerFactory _loggerFactory = loggerFactory;
|
||||||
|
private readonly ILibraryManager _libraryManager = libraryManager;
|
||||||
|
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
||||||
|
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyze all media items on the server.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="progress">Progress reporter.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <param name="seasonsToAnalyze">Season IDs to analyze.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
public async Task AnalyzeItemsAsync(
|
||||||
|
IProgress<double> progress,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
IReadOnlyCollection<Guid>? seasonsToAnalyze = null)
|
||||||
|
{
|
||||||
|
// Assert that ffmpeg with chromaprint is installed
|
||||||
|
if (_config.WithChromaprint && !FFmpegWrapper.CheckFFmpegVersion())
|
||||||
|
{
|
||||||
|
throw new FingerprintException(
|
||||||
|
"Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg7. If Jellyfin is running in a container, upgrade to version 10.10.0 or newer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<AnalysisMode> modes = [
|
||||||
|
.. _config.ScanIntroduction ? [AnalysisMode.Introduction] : Array.Empty<AnalysisMode>(),
|
||||||
|
.. _config.ScanCredits ? [AnalysisMode.Credits] : Array.Empty<AnalysisMode>(),
|
||||||
|
.. _config.ScanRecap ? [AnalysisMode.Recap] : Array.Empty<AnalysisMode>(),
|
||||||
|
.. _config.ScanPreview ? [AnalysisMode.Preview] : Array.Empty<AnalysisMode>()
|
||||||
|
];
|
||||||
|
|
||||||
|
var queueManager = new QueueManager(
|
||||||
|
_loggerFactory.CreateLogger<QueueManager>(),
|
||||||
|
_libraryManager);
|
||||||
|
|
||||||
|
var queue = queueManager.GetMediaItems();
|
||||||
|
|
||||||
|
if (seasonsToAnalyze?.Count > 0)
|
||||||
|
{
|
||||||
|
queue = queue.Where(kvp => seasonsToAnalyze.Contains(kvp.Key))
|
||||||
|
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalQueued = queue.Sum(kvp => kvp.Value.Count) * modes.Count;
|
||||||
|
if (totalQueued == 0)
|
||||||
|
{
|
||||||
|
throw new FingerprintException(
|
||||||
|
"No libraries selected for analysis. Please visit the plugin settings to configure.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalProcessed = 0;
|
||||||
|
var options = new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = Math.Max(1, _config.MaxParallelism),
|
||||||
|
CancellationToken = cancellationToken
|
||||||
|
};
|
||||||
|
|
||||||
|
await Parallel.ForEachAsync(queue, options, async (season, ct) =>
|
||||||
|
{
|
||||||
|
var updateMediaSegments = false;
|
||||||
|
|
||||||
|
var (episodes, requiredModes) = queueManager.VerifyQueue(season.Value, modes);
|
||||||
|
if (episodes.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var firstEpisode = episodes[0];
|
||||||
|
if (modes.Count != requiredModes.Count)
|
||||||
|
{
|
||||||
|
Interlocked.Add(ref totalProcessed, episodes.Count * (modes.Count - requiredModes.Count));
|
||||||
|
progress.Report((double)totalProcessed / totalQueued * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var mode in requiredModes)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
int analyzed = await AnalyzeItemsAsync(
|
||||||
|
episodes,
|
||||||
|
mode,
|
||||||
|
ct).ConfigureAwait(false);
|
||||||
|
Interlocked.Add(ref totalProcessed, analyzed);
|
||||||
|
|
||||||
|
updateMediaSegments = analyzed > 0 || updateMediaSegments;
|
||||||
|
progress.Report((double)totalProcessed / totalQueued * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Analysis was canceled.");
|
||||||
|
}
|
||||||
|
catch (FingerprintException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Fingerprint exception during analysis.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "An unexpected error occurred during analysis.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_config.RebuildMediaSegments || (updateMediaSegments && _config.UpdateMediaSegments))
|
||||||
|
{
|
||||||
|
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Plugin.Instance!.AnalyzeAgain = false;
|
||||||
|
|
||||||
|
if (_config.RebuildMediaSegments)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Regenerated media segments.");
|
||||||
|
_config.RebuildMediaSegments = 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 successfully analyzed.</returns>
|
||||||
|
private async Task<int> AnalyzeItemsAsync(
|
||||||
|
IReadOnlyList<QueuedEpisode> items,
|
||||||
|
AnalysisMode mode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var first = items[0];
|
||||||
|
if (!first.IsMovie && first.SeasonNumber == 0 && !_config.AnalyzeSeasonZero)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the IsAnalyzed flag for all items
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
item.IsAnalyzed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the analyzer action for the current mode
|
||||||
|
var action = Plugin.Instance!.GetAnalyzerAction(first.SeasonId, mode);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}",
|
||||||
|
mode,
|
||||||
|
items.Count,
|
||||||
|
first.SeriesName,
|
||||||
|
first.SeasonNumber);
|
||||||
|
|
||||||
|
// Create a list of analyzers to use for the current mode
|
||||||
|
var analyzers = new List<IMediaFileAnalyzer>();
|
||||||
|
|
||||||
|
if (action is AnalyzerAction.Chapter or AnalyzerAction.Default)
|
||||||
|
{
|
||||||
|
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.IsAnime && _config.WithChromaprint &&
|
||||||
|
mode is not (AnalysisMode.Recap or AnalysisMode.Preview) &&
|
||||||
|
action is AnalyzerAction.Default or AnalyzerAction.Chromaprint)
|
||||||
|
{
|
||||||
|
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode is AnalysisMode.Credits &&
|
||||||
|
action is AnalyzerAction.Default or AnalyzerAction.BlackFrame)
|
||||||
|
{
|
||||||
|
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!first.IsAnime && !first.IsMovie &&
|
||||||
|
mode is not (AnalysisMode.Recap or AnalysisMode.Preview) &&
|
||||||
|
action is AnalyzerAction.Default or AnalyzerAction.Chromaprint)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
items = await analyzer.AnalyzeMediaFiles(items, mode, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the episode IDs for the analyzed items
|
||||||
|
await Plugin.Instance!.SetEpisodeIdsAsync(first.SeasonId, mode, items.Select(i => i.EpisodeId)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return items.Where(i => i.IsAnalyzed).Count();
|
||||||
|
}
|
||||||
|
}
|
121
IntroSkipper/ScheduledTasks/CleanCacheTask.cs
Normal file
121
IntroSkipper/ScheduledTasks/CleanCacheTask.cs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Manager;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper.ScheduledTasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyze all television episodes for introduction sequences.
|
||||||
|
/// </summary>
|
||||||
|
public class CleanCacheTask : IScheduledTask
|
||||||
|
{
|
||||||
|
private readonly ILogger<CleanCacheTask> _logger;
|
||||||
|
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CleanCacheTask"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">Logger factory.</param>
|
||||||
|
/// <param name="libraryManager">Library manager.</param>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
public CleanCacheTask(
|
||||||
|
ILogger<CleanCacheTask> logger,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
ILibraryManager libraryManager)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_loggerFactory = loggerFactory;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the task name.
|
||||||
|
/// </summary>
|
||||||
|
public string Name => "Clean Intro Skipper Cache";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the task category.
|
||||||
|
/// </summary>
|
||||||
|
public string Category => "Intro Skipper";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the task description.
|
||||||
|
/// </summary>
|
||||||
|
public string Description => "Clear Intro Skipper cache of unused files.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the task key.
|
||||||
|
/// </summary>
|
||||||
|
public string Key => "CPBIntroSkipperCleanCache";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cleans the cache of unused files.
|
||||||
|
/// Clears the Segment cache by removing files that are no longer associated with episodes in the library.
|
||||||
|
/// Clears the IgnoreList cache by removing items that are no longer associated with seasons in the library.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="progress">Task progress.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Task.</returns>
|
||||||
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_libraryManager is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Library manager was null");
|
||||||
|
}
|
||||||
|
|
||||||
|
var queueManager = new QueueManager(
|
||||||
|
_loggerFactory.CreateLogger<QueueManager>(),
|
||||||
|
_libraryManager);
|
||||||
|
|
||||||
|
// Retrieve media items and get valid episode IDs
|
||||||
|
var queue = queueManager.GetMediaItems();
|
||||||
|
var validEpisodeIds = queue.Values
|
||||||
|
.SelectMany(episodes => episodes.Select(e => e.EpisodeId))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
await Plugin.Instance!.CleanTimestamps(validEpisodeIds).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Identify invalid episode IDs
|
||||||
|
var invalidEpisodeIds = Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath)
|
||||||
|
.Select(filePath => Path.GetFileNameWithoutExtension(filePath).Split('-')[0])
|
||||||
|
.Where(episodeIdStr => Guid.TryParse(episodeIdStr, out var episodeId) && !validEpisodeIds.Contains(episodeId))
|
||||||
|
.Select(Guid.Parse)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
// Delete cache files for invalid episode IDs
|
||||||
|
foreach (var episodeId in invalidEpisodeIds)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Deleting cache files for episode ID: {EpisodeId}", episodeId);
|
||||||
|
FFmpegWrapper.DeleteEpisodeCache(episodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up Season information by removing items that are no longer exist.
|
||||||
|
await Plugin.Instance!.CleanSeasonInfoAsync(queue.Keys).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Plugin.Instance!.AnalyzeAgain = true;
|
||||||
|
|
||||||
|
progress.Report(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get task triggers.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Task triggers.</returns>
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
109
IntroSkipper/ScheduledTasks/DetectSegmentsTask.cs
Normal file
109
IntroSkipper/ScheduledTasks/DetectSegmentsTask.cs
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using IntroSkipper.Manager;
|
||||||
|
using IntroSkipper.Services;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace IntroSkipper.ScheduledTasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyze all television episodes for media segments.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="DetectSegmentsTask"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="loggerFactory">Logger factory.</param>
|
||||||
|
/// <param name="libraryManager">Library manager.</param>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
/// <param name="mediaSegmentUpdateManager">MediaSegment Update Manager.</param>
|
||||||
|
public class DetectSegmentsTask(
|
||||||
|
ILogger<DetectSegmentsTask> logger,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
MediaSegmentUpdateManager mediaSegmentUpdateManager) : IScheduledTask
|
||||||
|
{
|
||||||
|
private readonly ILogger<DetectSegmentsTask> _logger = logger;
|
||||||
|
|
||||||
|
private readonly ILoggerFactory _loggerFactory = loggerFactory;
|
||||||
|
|
||||||
|
private readonly ILibraryManager _libraryManager = libraryManager;
|
||||||
|
|
||||||
|
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the task name.
|
||||||
|
/// </summary>
|
||||||
|
public string Name => "Detect and Analyze Media Segments";
|
||||||
|
|
||||||
|
/// <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 => "IntroSkipperDetectSegmentsTask";
|
||||||
|
|
||||||
|
/// <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 async 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 {TaskState} and will be canceled.", Entrypoint.AutomaticTaskState);
|
||||||
|
await Entrypoint.CancelAutomaticTaskAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (await ScheduledTaskSemaphore.AcquireAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Scheduled Task is starting");
|
||||||
|
|
||||||
|
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
|
||||||
|
_loggerFactory.CreateLogger<DetectSegmentsTask>(),
|
||||||
|
_loggerFactory,
|
||||||
|
_libraryManager,
|
||||||
|
_mediaSegmentUpdateManager);
|
||||||
|
|
||||||
|
await baseIntroAnalyzer.AnalyzeItemsAsync(progress, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get task triggers.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Task triggers.</returns>
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new TaskTriggerInfo
|
||||||
|
{
|
||||||
|
Type = TaskTriggerInfo.TriggerDaily,
|
||||||
|
TimeOfDayTicks = TimeSpan.FromHours(0).Ticks
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user