├── .editorconfig
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ └── feature_request.md
├── dependabot.yml
└── workflows
│ ├── build.yml
│ ├── container.yml
│ ├── package.sh
│ └── publish.yml
├── .gitignore
├── ACKNOWLEDGEMENTS.md
├── CHANGELOG.md
├── ConfusedPolarBear.Plugin.IntroSkipper.Tests
├── ConfusedPolarBear.Plugin.IntroSkipper.Tests.csproj
├── TestAudioFingerprinting.cs
├── TestBlackFrames.cs
├── TestChapterAnalyzer.cs
├── TestContiguous.cs
├── TestEdl.cs
├── TestWarnings.cs
├── audio
│ ├── README.txt
│ ├── big_buck_bunny_clip.mp3
│ └── big_buck_bunny_intro.mp3
├── e2e_tests
│ ├── .gitignore
│ ├── README.md
│ ├── build.sh
│ ├── config_sample.jsonc
│ ├── docker-compose.yml
│ ├── reports
│ │ └── .keep
│ ├── selenium
│ │ ├── main.py
│ │ ├── requirements.txt
│ │ └── screenshots
│ │ │ └── .keep
│ ├── verifier
│ │ ├── go.mod
│ │ ├── http.go
│ │ ├── main.go
│ │ ├── report.html
│ │ ├── report_comparison.go
│ │ ├── report_comparison_util.go
│ │ ├── report_generator.go
│ │ ├── schema_validation.go
│ │ └── structs
│ │ │ ├── intro.go
│ │ │ ├── plugin_configuration.go
│ │ │ ├── public_info.go
│ │ │ └── report.go
│ └── wrapper
│ │ ├── exec.go
│ │ ├── exec_test.go
│ │ ├── go.mod
│ │ ├── library.json
│ │ ├── main.go
│ │ ├── setup.go
│ │ └── structs.go
└── video
│ ├── credits.mp4
│ └── rainbow.mp4
├── ConfusedPolarBear.Plugin.IntroSkipper.sln
├── ConfusedPolarBear.Plugin.IntroSkipper
├── Analyzers
│ ├── BlackFrameAnalyzer.cs
│ ├── ChapterAnalyzer.cs
│ ├── ChromaprintAnalyzer.cs
│ └── IMediaFileAnalyzer.cs
├── AutoSkip.cs
├── Configuration
│ ├── PluginConfiguration.cs
│ ├── UserInterfaceConfiguration.cs
│ ├── configPage.html
│ ├── inject.js
│ ├── version.txt
│ └── visualizer.js
├── ConfusedPolarBear.Plugin.IntroSkipper.csproj
├── Controllers
│ ├── SkipIntroController.cs
│ ├── TroubleshootingController.cs
│ └── VisualizationController.cs
├── Data
│ ├── AnalysisMode.cs
│ ├── BlackFrame.cs
│ ├── EdlAction.cs
│ ├── EpisodeVisualization.cs
│ ├── FingerprintException.cs
│ ├── Intro.cs
│ ├── PluginWarning.cs
│ ├── QueuedEpisode.cs
│ └── TimeRange.cs
├── EdlManager.cs
├── Entrypoint.cs
├── FFmpegWrapper.cs
├── Plugin.cs
├── QueueManager.cs
└── ScheduledTasks
│ ├── BaseItemAnalyzerTask.cs
│ ├── DetectCreditsTask.cs
│ └── DetectIntroductionsTask.cs
├── LICENSE
├── README.md
├── docker
├── Dockerfile
└── README.md
├── docs
├── api.md
├── debug_logs.md
├── edl.md
├── native.md
├── release.md
└── web_interface.md
├── images
├── logo.png
└── skip-button.png
├── jellyfin.ruleset
└── manifest.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | # With more recent updates Visual Studio 2017 supports EditorConfig files out of the box
2 | # Visual Studio Code needs an extension: https://github.com/editorconfig/editorconfig-vscode
3 | # For emacs, vim, np++ and other editors, see here: https://github.com/editorconfig
4 | ###############################
5 | # Core EditorConfig Options #
6 | ###############################
7 | root = true
8 | # All files
9 | [*]
10 | indent_style = space
11 | indent_size = 4
12 | charset = utf-8
13 | trim_trailing_whitespace = true
14 | insert_final_newline = true
15 | end_of_line = lf
16 | max_line_length = off
17 |
18 | # YAML indentation
19 | [*.{yml,yaml}]
20 | indent_size = 2
21 |
22 | # XML indentation
23 | [*.{csproj,xml}]
24 | indent_size = 2
25 |
26 | ###############################
27 | # .NET Coding Conventions #
28 | ###############################
29 | [*.{cs,vb}]
30 | # Organize usings
31 | dotnet_sort_system_directives_first = true
32 | # this. preferences
33 | dotnet_style_qualification_for_field = false:silent
34 | dotnet_style_qualification_for_property = false:silent
35 | dotnet_style_qualification_for_method = false:silent
36 | dotnet_style_qualification_for_event = false:silent
37 | # Language keywords vs BCL types preferences
38 | dotnet_style_predefined_type_for_locals_parameters_members = true:silent
39 | dotnet_style_predefined_type_for_member_access = true:silent
40 | # Parentheses preferences
41 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
42 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
43 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
44 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
45 | # Modifier preferences
46 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
47 | dotnet_style_readonly_field = true:suggestion
48 | # Expression-level preferences
49 | dotnet_style_object_initializer = true:suggestion
50 | dotnet_style_collection_initializer = true:suggestion
51 | dotnet_style_explicit_tuple_names = true:suggestion
52 | dotnet_style_null_propagation = true:suggestion
53 | dotnet_style_coalesce_expression = true:suggestion
54 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent
55 | dotnet_style_prefer_inferred_tuple_names = true:suggestion
56 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
57 | dotnet_style_prefer_auto_properties = true:silent
58 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent
59 | dotnet_style_prefer_conditional_expression_over_return = true:silent
60 |
61 | ###############################
62 | # Naming Conventions #
63 | ###############################
64 | # Style Definitions (From Roslyn)
65 |
66 | # Non-private static fields are PascalCase
67 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion
68 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields
69 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style
70 |
71 | dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field
72 | dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
73 | dotnet_naming_symbols.non_private_static_fields.required_modifiers = static
74 |
75 | dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case
76 |
77 | # Constants are PascalCase
78 | dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
79 | dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
80 | dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style
81 |
82 | dotnet_naming_symbols.constants.applicable_kinds = field, local
83 | dotnet_naming_symbols.constants.required_modifiers = const
84 |
85 | dotnet_naming_style.constant_style.capitalization = pascal_case
86 |
87 | # Static fields are camelCase and start with s_
88 | dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion
89 | dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields
90 | dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style
91 |
92 | dotnet_naming_symbols.static_fields.applicable_kinds = field
93 | dotnet_naming_symbols.static_fields.required_modifiers = static
94 |
95 | dotnet_naming_style.static_field_style.capitalization = camel_case
96 | dotnet_naming_style.static_field_style.required_prefix = _
97 |
98 | # Instance fields are camelCase and start with _
99 | dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion
100 | dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields
101 | dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style
102 |
103 | dotnet_naming_symbols.instance_fields.applicable_kinds = field
104 |
105 | dotnet_naming_style.instance_field_style.capitalization = camel_case
106 | dotnet_naming_style.instance_field_style.required_prefix = _
107 |
108 | # Locals and parameters are camelCase
109 | dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion
110 | dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters
111 | dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style
112 |
113 | dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local
114 |
115 | dotnet_naming_style.camel_case_style.capitalization = camel_case
116 |
117 | # Local functions are PascalCase
118 | dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion
119 | dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions
120 | dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style
121 |
122 | dotnet_naming_symbols.local_functions.applicable_kinds = local_function
123 |
124 | dotnet_naming_style.local_function_style.capitalization = pascal_case
125 |
126 | # By default, name items with PascalCase
127 | dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion
128 | dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members
129 | dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style
130 |
131 | dotnet_naming_symbols.all_members.applicable_kinds = *
132 |
133 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case
134 |
135 | ###############################
136 | # C# Coding Conventions #
137 | ###############################
138 | [*.cs]
139 | # var preferences
140 | csharp_style_var_for_built_in_types = true:silent
141 | csharp_style_var_when_type_is_apparent = true:silent
142 | csharp_style_var_elsewhere = true:silent
143 | # Expression-bodied members
144 | csharp_style_expression_bodied_methods = false:silent
145 | csharp_style_expression_bodied_constructors = false:silent
146 | csharp_style_expression_bodied_operators = false:silent
147 | csharp_style_expression_bodied_properties = true:silent
148 | csharp_style_expression_bodied_indexers = true:silent
149 | csharp_style_expression_bodied_accessors = true:silent
150 | # Pattern matching preferences
151 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
152 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
153 | # Null-checking preferences
154 | csharp_style_throw_expression = true:suggestion
155 | csharp_style_conditional_delegate_call = true:suggestion
156 | # Modifier preferences
157 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion
158 | # Expression-level preferences
159 | csharp_prefer_braces = true:silent
160 | csharp_style_deconstructed_variable_declaration = true:suggestion
161 | csharp_prefer_simple_default_expression = true:suggestion
162 | csharp_style_pattern_local_over_anonymous_function = true:suggestion
163 | csharp_style_inlined_variable_declaration = true:suggestion
164 |
165 | ###############################
166 | # C# Formatting Rules #
167 | ###############################
168 | # New line preferences
169 | csharp_new_line_before_open_brace = all
170 | csharp_new_line_before_else = true
171 | csharp_new_line_before_catch = true
172 | csharp_new_line_before_finally = true
173 | csharp_new_line_before_members_in_object_initializers = true
174 | csharp_new_line_before_members_in_anonymous_types = true
175 | csharp_new_line_between_query_expression_clauses = true
176 | # Indentation preferences
177 | csharp_indent_case_contents = true
178 | csharp_indent_switch_labels = true
179 | csharp_indent_labels = flush_left
180 | # Space preferences
181 | csharp_space_after_cast = false
182 | csharp_space_after_keywords_in_control_flow_statements = true
183 | csharp_space_between_method_call_parameter_list_parentheses = false
184 | csharp_space_between_method_declaration_parameter_list_parentheses = false
185 | csharp_space_between_parentheses = false
186 | csharp_space_before_colon_in_inheritance_clause = true
187 | csharp_space_after_colon_in_inheritance_clause = true
188 | csharp_space_around_binary_operators = before_and_after
189 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
190 | csharp_space_between_method_call_name_and_opening_parenthesis = false
191 | csharp_space_between_method_call_empty_parameter_list_parentheses = false
192 | # Wrapping preferences
193 | csharp_preserve_single_line_statements = true
194 | csharp_preserve_single_line_blocks = true
195 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: "Bug report"
2 | description: "Create a report to help us improve"
3 | labels: [bug]
4 | body:
5 | - type: textarea
6 | attributes:
7 | label: Describe the bug
8 | description: Also tell us, what did you expect to happen?
9 | placeholder: |
10 | 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.
11 |
12 | This is my issue.
13 |
14 | Steps to Reproduce
15 | 1. In this environment...
16 | 2. With this config...
17 | 3. Run '...'
18 | 4. See error...
19 | validations:
20 | required: true
21 |
22 | - type: input
23 | attributes:
24 | label: Operating system
25 | placeholder: Debian 11, Windows 11, etc.
26 | validations:
27 | required: true
28 |
29 | - type: input
30 | attributes:
31 | label: Jellyfin installation method
32 | placeholder: Docker, Windows installer, etc.
33 | validations:
34 | required: true
35 |
36 | - type: input
37 | attributes:
38 | label: Container image and tag
39 | description: Only fill in this field if you are running Jellyfin in a container
40 | placeholder: jellyfin/jellyfin:10.8.7, jellyfin-intro-skipper:latest, etc.
41 |
42 | - type: textarea
43 | attributes:
44 | label: Support Bundle
45 | placeholder: go to Dashboard -> Plugins -> Intro Skipper -> Support Bundle (at the bottom of the page) and paste the contents of the textbox here
46 | validations:
47 | required: true
48 |
49 | - type: textarea
50 | attributes:
51 | label: Jellyfin logs
52 | placeholder: Paste any relevant logs here
53 | render: shell
54 |
55 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | labels: enhancement
5 | assignees: ''
6 |
7 | ---
8 |
9 | **Describe the feature you'd like added**
10 | A clear and concise description of what you would like to see added.
11 |
12 | **Additional context**
13 | Add any other context or screenshots about the feature request here.
14 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # Fetch and update latest `nuget` pkgs
4 | - package-ecosystem: nuget
5 | directory: /
6 | schedule:
7 | interval: weekly
8 | open-pull-requests-limit: 10
9 | labels:
10 | - chore
11 | - dependency
12 | - nuget
13 | commit-message:
14 | prefix: chore
15 | include: scope
16 |
17 | # Fetch and update latest `github-actions` pkgs
18 | - package-ecosystem: github-actions
19 | directory: /
20 | schedule:
21 | interval: monthly
22 | open-pull-requests-limit: 10
23 | labels:
24 | - ci
25 | - dependency
26 | - github_actions
27 | commit-message:
28 | prefix: ci
29 | include: scope
30 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: 'Build Plugin'
2 |
3 | on:
4 | push:
5 | branches: [ "master", "analyzers" ]
6 | pull_request:
7 | branches: [ "master" ]
8 |
9 | jobs:
10 | build:
11 |
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - uses: actions/checkout@v3
16 |
17 | - name: Setup .NET
18 | uses: actions/setup-dotnet@v2
19 | with:
20 | dotnet-version: 6.0.x
21 |
22 | - name: Restore dependencies
23 | run: dotnet restore
24 |
25 | - name: Embed version info
26 | run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt
27 |
28 | - name: Build
29 | run: dotnet build --no-restore
30 |
31 | - name: Upload artifact
32 | uses: actions/upload-artifact@v3.1.2
33 | with:
34 | name: intro-skipper-${{ github.sha }}.dll
35 | path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
36 | if-no-files-found: error
37 |
--------------------------------------------------------------------------------
/.github/workflows/container.yml:
--------------------------------------------------------------------------------
1 | name: Publish container
2 |
3 | on:
4 | release:
5 | types: [published]
6 | workflow_dispatch:
7 |
8 | env:
9 | REGISTRY: ghcr.io
10 | IMAGE_NAME: ${{ github.repository_owner }}/jellyfin-intro-skipper
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 | permissions:
16 | contents: read
17 | packages: write
18 |
19 | strategy:
20 | matrix:
21 | node-version: [18.x]
22 | jellyfin-container-version: [10.8.10]
23 | jellyfin-web-version: [10.8.10]
24 |
25 | steps:
26 | - uses: actions/checkout@v3
27 |
28 | - name: Use Node.js ${{ matrix.node-version }}
29 | uses: actions/setup-node@v3
30 | with:
31 | node-version: ${{ matrix.node-version }}
32 |
33 | - name: Get npm cache directory
34 | id: npm-cache-dir
35 | run: |
36 | echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
37 | - name: Configure npm cache
38 | uses: actions/cache@v3
39 | id: npm-cache
40 | with:
41 | path: ${{ steps.npm-cache-dir.outputs.dir }}
42 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
43 | restore-keys: |
44 | ${{ runner.os }}-node-
45 | - name: Checkout modified web interface
46 | uses: actions/checkout@v3
47 | with:
48 | repository: ConfusedPolarBear/jellyfin-web
49 | ref: intros
50 | path: web
51 |
52 | - name: Store commit of web interface
53 | id: web-commit
54 | run: |
55 | cd web
56 | echo "commit=$(git log -1 --format='%H' | cut -c -10)" >> $GITHUB_OUTPUT
57 | - name: Build and copy web interface
58 | run: |
59 | cd web
60 | npm install
61 | cp -r dist ../docker/
62 | tar czf dist.tar.gz dist
63 | - name: Upload web interface
64 | uses: actions/upload-artifact@v3.1.2
65 | with:
66 | name: jellyfin-web-${{ matrix.jellyfin-web-version }}+${{ steps.web-commit.outputs.commit }}.tar.gz
67 | path: web/dist.tar.gz
68 | if-no-files-found: error
69 |
70 | - name: Set up QEMU
71 | uses: docker/setup-qemu-action@v2
72 |
73 | - name: Setup Docker buildx
74 | uses: docker/setup-buildx-action@v2
75 |
76 | # Login against a Docker registry except on PR
77 | # https://github.com/docker/login-action
78 | - name: Log into ghcr.io registry
79 | if: github.event_name != 'pull_request'
80 | uses: docker/login-action@v2.1.0
81 | with:
82 | registry: ${{ env.REGISTRY }}
83 | username: ${{ github.actor }}
84 | password: ${{ secrets.GITHUB_TOKEN }}
85 |
86 | # Extract metadata (tags, labels) for Docker
87 | # https://github.com/docker/metadata-action
88 | - name: Extract Docker metadata
89 | id: meta
90 | uses: docker/metadata-action@v4
91 | with:
92 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
93 | tags: |
94 | type-raw,value=${{ steps.web-commit.outputs.commit }}
95 | type=raw,value=latest,enable={{is_default_branch}}
96 | type=semver,pattern={{version}},value=${{ matrix.jellyfin-container-version }}
97 | # Build and push Docker image with Buildx
98 | # https://github.com/docker/build-push-action
99 | - name: Publish container image
100 | id: build-and-push
101 | uses: docker/build-push-action@v4.0.0
102 | with:
103 | file: docker/Dockerfile
104 | context: docker
105 | tags: ${{ steps.meta.outputs.tags }}
106 | labels: ${{ steps.meta.outputs.labels }}
107 | build-args: |
108 | JELLYFIN_TAG=${{ matrix.jellyfin-container-version }}
109 | platforms: |
110 | linux/amd64
111 | linux/arm/v7
112 | linux/arm64/v8
113 | push: ${{ github.event_name != 'pull_request' }}
114 | pull: true
115 | no-cache: true
116 | cache-from: type=gha
117 | cache-to: type=gha,mode=max
118 | provenance: false
119 |
--------------------------------------------------------------------------------
/.github/workflows/package.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Check argument count
4 | if [[ $# -ne 1 ]]; then
5 | echo "Usage: $0 VERSION"
6 | exit 1
7 | fi
8 |
9 | # Use provided tag to derive archive filename and short tag
10 | version="$1"
11 | zip="intro-skipper-$version.zip"
12 | short="$(echo "$version" | sed "s/^v//")"
13 |
14 | # Get the assembly version
15 | CSPROJ="ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj"
16 | assemblyVersion="$(grep -m1 -oE "([0-9]\.){3}[0-9]" "$CSPROJ")"
17 |
18 | # Get the date
19 | date="$(date --utc -Iseconds | sed "s/\+00:00/Z/")"
20 |
21 | # Debug
22 | echo "Version: $version ($short)"
23 | echo "Archive: $zip"
24 | echo
25 |
26 | echo "Running unit tests"
27 | dotnet test -p:DefineConstants=SKIP_FFMPEG_TESTS || exit 1
28 | echo
29 |
30 | echo "Building plugin in Release mode"
31 | dotnet build -c Release || exit 1
32 | echo
33 |
34 | # Create packaging directory
35 | mkdir package
36 | cd package || exit 1
37 |
38 | # Copy the freshly built plugin DLL to the packaging directory and archive
39 | cp "../ConfusedPolarBear.Plugin.IntroSkipper/bin/Release/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll" ./ || exit 1
40 | zip "$zip" ConfusedPolarBear.Plugin.IntroSkipper.dll || exit 1
41 |
42 | # Calculate the checksum of the archive
43 | checksum="$(md5sum "$zip" | cut -f 1 -d " ")"
44 |
45 | # Generate the manifest entry for this plugin
46 | cat > manifest.json <<'EOF'
47 | {
48 | "version": "ASSEMBLY",
49 | "changelog": "- See the full changelog at [GitHub](https://github.com/ConfusedPolarBear/intro-skipper/blob/master/CHANGELOG.md)\n",
50 | "targetAbi": "10.8.4.0",
51 | "sourceUrl": "https://github.com/ConfusedPolarBear/intro-skipper/releases/download/VERSION/ZIP",
52 | "checksum": "CHECKSUM",
53 | "timestamp": "DATE"
54 | }
55 | EOF
56 |
57 | sed -i "s/ASSEMBLY/$assemblyVersion/" manifest.json
58 | sed -i "s/VERSION/$version/" manifest.json
59 | sed -i "s/ZIP/$zip/" manifest.json
60 | sed -i "s/CHECKSUM/$checksum/" manifest.json
61 | sed -i "s/DATE/$date/" manifest.json
62 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: 'Package plugin'
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | build:
8 | runs-on: ubuntu-22.04
9 |
10 | steps:
11 | # set fetch-depth to 0 in order to clone all tags instead of just the current commit
12 | - uses: actions/checkout@v3
13 | with:
14 | fetch-depth: 0
15 |
16 | - name: Checkout latest tag
17 | id: tag
18 | run: |
19 | tag="$(git tag --sort=committerdate | tail -n 1)"
20 | git checkout "$tag"
21 | echo "tag=$tag" >> $GITHUB_OUTPUT
22 |
23 | - name: Setup .NET
24 | uses: actions/setup-dotnet@v2
25 | with:
26 | dotnet-version: 6.0.x
27 |
28 | - name: Restore dependencies
29 | run: dotnet restore
30 |
31 | - name: Package
32 | run: .github/workflows/package.sh ${{ steps.tag.outputs.tag }}
33 |
34 | - name: Upload plugin archive
35 | uses: actions/upload-artifact@v3.1.2
36 | with:
37 | name: intro-skipper-bundle-${{ steps.tag.outputs.tag }}.zip
38 | path: |
39 | package/*.zip
40 | package/*.json
41 | if-no-files-found: error
42 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | bin/
2 | obj/
3 | BenchmarkDotNet.Artifacts/
4 | /package/
5 |
6 | # Ignore pre compiled web interface
7 | docker/dist
8 |
--------------------------------------------------------------------------------
/ACKNOWLEDGEMENTS.md:
--------------------------------------------------------------------------------
1 | Intro Skipper is made possible by the following open source projects:
2 |
3 | * [acoustid-match](https://github.com/dnknth/acoustid-match) (MIT)
4 | * [chromaprint](https://github.com/acoustid/chromaprint) (LGPL 2.1)
5 | * [JellyScrub](https://github.com/nicknsy/jellyscrub) (MIT)
6 | * [Jellyfin](https://github.com/jellyfin/jellyfin) (GPL)
7 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## v0.1.8.0 (no eta)
4 | * New features
5 | * Support adding skip intro button to web interface without using a fork
6 | * Add localization support for the skip intro button and the automatic skip notification message
7 | * Detect ending credits in television episodes
8 | * Add support for using chapter names to locate introductions and ending credits
9 | * Add support for using black frames to locate ending credits
10 | * Show skip button when on screen controls are visible (#149 by @DualScorch)
11 | * Internal changes
12 | * Move Chromaprint analysis code out of the episode analysis task
13 | * Add support for multiple analysis techinques
14 |
15 | ## v0.1.7.0 (2022-10-26)
16 | * New features
17 | * Rewrote fingerprint comparison algorithm to be faster (~30x speedup) and detect more introductions
18 | * Detect silence at the end of introductions and use it to avoid skipping over the beginning of an episode
19 | * If you are upgrading from a previous release and want to use the silence detection feature on shows that have already been analyzed, you must click the `Erase introduction timestamps` button at the bottom of the plugin settings page
20 | * Add support bundle
21 | * Add maximum introduction duration
22 | * Support playing a few seconds from the end of the introduction to verify that no episode content was skipped over
23 | * Amount played is customizable and defaults to 2 seconds
24 | * Support modifying introduction detection algorithm settings
25 | * Add option to not skip the introduction in the first episode of a season
26 | * Add option to analyze show extras (specials)
27 | * Fixes
28 | * Fix scheduled task interval (#79)
29 | * Prevent show names from becoming duplicated in the show name dropdown under the advanced section
30 | * Prevent virtual episodes from being inserted into the analysis queue
31 |
32 | ## v0.1.6.0 (2022-08-04)
33 | * New features
34 | * Generate EDL files with intro timestamps ([documentation](docs/edl.md)) (#21)
35 | * Support selecting which libraries are analyzed (#37)
36 | * Support customizing [introduction requirements](README.md#introduction-requirements) (#38, #51)
37 | * Changing these settings will increase episode analysis times
38 | * Support adding and editing intro timestamps (#26)
39 | * Report how CPU time is being spent while analyzing episodes
40 | * CPU time reports can be viewed under "Analysis Statistics (experimental)" in the plugin configuration page
41 | * Sped up fingerprint analysis (not including fingerprint generation time) by 40%
42 | * Support erasing discovered introductions by season
43 | * Suggest potential shifts in the fingerprint visualizer
44 |
45 | * Fixes
46 | * Ensure episode analysis queue matches the current filesystem and library state (#42, #60)
47 | * Fixes a bug where renamed or deleted episodes were being analyzed
48 | * Fix automatic intro skipping on Android TV (#57, #61)
49 | * Restore per season status updates in the log
50 | * Prevent null key in `/Intros/Shows` endpoint (#27)
51 | * Fix positioning of skip intro button on mobile devices (#43)
52 | * Ensure video playback always resumes after clicking the skip intro button (#44)
53 |
54 | ## v0.1.5.0 (2022-06-17)
55 | * Use `ffmpeg` to generate audio fingerprints instead of `fpcalc`
56 | * Requires that the installed version of `ffmpeg`:
57 | * Was compiled with the `--enable-chromaprint` option
58 | * Understands the `-fp_format raw` flag
59 | * `jellyfin-ffmpeg 5.0.1-5` meets both of these requirements
60 | * Version API endpoints
61 | * See [api.md](docs/api.md) for detailed documentation on how clients can work with this plugin
62 | * Add commit hash to unstable builds
63 | * Log media paths that are unable to be fingerprinted
64 | * Report failure to the UI if the episode analysis queue is empty
65 | * Allow customizing degrees of parallelism
66 | * Warning: Using a value that is too high will result in system instability
67 | * Remove restart requirement to change auto skip setting
68 | * Rewrite startup enqueue
69 | * Fix deadlock issue on Windows (#23 by @nyanmisaka)
70 | * Improve skip intro button styling & positioning (ConfusedPolarBear/jellyfin-web#91 by @Fallenbagel)
71 | * Order episodes by `IndexNumber` (#25 reported by @Flo56958)
72 |
73 |
74 | ## v0.1.0.0 (2022-06-09)
75 | * Add option to automatically skip intros
76 | * Cache audio fingerprints by default
77 | * Add fingerprint visualizer
78 | * Add button to erase all previously discovered intro timestamps
79 | * Made saving settings more reliable
80 | * Switch to new fingerprint comparison algorithm
81 | * If you would like to test the new comparison algorithm, you will have to erase all previously discovered introduction timestamps.
82 |
83 | ## v0.0.0.3 (2022-05-21)
84 | * Fix `fpcalc` version check
85 |
86 | ## v0.0.0.2 (2022-05-21)
87 | * Analyze multiple seasons in parallel
88 | * Reanalyze episodes with an unusually short or long intro sequence
89 | * Check installed `fpcalc` version
90 | * Clarify installation instructions
91 |
92 | ## v0.0.0.1 (2022-05-10)
93 | * First alpha build
94 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/ConfusedPolarBear.Plugin.IntroSkipper.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 | enable
6 |
7 | false
8 |
9 |
10 |
11 |
12 |
13 |
14 | runtime; build; native; contentfiles; analyzers; buildtransitive
15 | all
16 |
17 |
18 | runtime; build; native; contentfiles; analyzers; buildtransitive
19 | all
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs:
--------------------------------------------------------------------------------
1 | /* These tests require that the host system has a version of FFmpeg installed
2 | * which supports both chromaprint and the "-fp_format raw" flag.
3 | */
4 |
5 | using System;
6 | using System.Collections.Generic;
7 | using Xunit;
8 | using Microsoft.Extensions.Logging;
9 |
10 | namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
11 |
12 | public class TestAudioFingerprinting
13 | {
14 | [FactSkipFFmpegTests]
15 | public void TestInstallationCheck()
16 | {
17 | Assert.True(FFmpegWrapper.CheckFFmpegVersion());
18 | }
19 |
20 | [Theory]
21 | [InlineData(0, 0)]
22 | [InlineData(1, 1)]
23 | [InlineData(5, 213)]
24 | [InlineData(10, 56_021)]
25 | [InlineData(16, 16_112_341)]
26 | [InlineData(19, 2_465_585_877)]
27 | public void TestBitCounting(int expectedBits, uint number)
28 | {
29 | var chromaprint = CreateChromaprintAnalyzer();
30 | Assert.Equal(expectedBits, chromaprint.CountBits(number));
31 | }
32 |
33 | [FactSkipFFmpegTests]
34 | public void TestFingerprinting()
35 | {
36 | // Generated with `fpcalc -raw audio/big_buck_bunny_intro.mp3`
37 | var expected = new uint[]{
38 | 3269995649, 3261610160, 3257403872, 1109989680, 1109993760, 1110010656, 1110142768, 1110175504,
39 | 1110109952, 1126874880, 2788611, 2787586, 6981634, 15304754, 28891170, 43579426, 43542561,
40 | 47737888, 41608640, 40559296, 36352644, 53117572, 2851460, 1076465548, 1080662428, 1080662492,
41 | 1089182044, 1148041501, 1148037422, 3291343918, 3290980398, 3429367854, 3437756714, 3433698090,
42 | 3433706282, 3366600490, 3366464314, 2296916250, 3362269210, 3362265115, 3362266441, 3370784472,
43 | 3366605480, 1218990776, 1223217816, 1231602328, 1260950200, 1245491640, 169845176, 1510908120,
44 | 1510911000, 2114365528, 2114370008, 1996929688, 1996921480, 1897171592, 1884588680, 1347470984,
45 | 1343427226, 1345467054, 1349657318, 1348673570, 1356869666, 1356865570, 295837698, 60957698,
46 | 44194818, 48416770, 40011778, 36944210, 303147954, 369146786, 1463847842, 1434488738, 1417709474,
47 | 1417713570, 3699441634, 3712167202, 3741460534, 2585144342, 2597725238, 2596200487, 2595926077,
48 | 2595984141, 2594734600, 2594736648, 2598931176, 2586348264, 2586348264, 2586561257, 2586451659,
49 | 2603225802, 2603225930, 2573860970, 2561151018, 3634901034, 3634896954, 3651674122, 3416793162,
50 | 3416816715, 3404331257, 3395844345, 3395836155, 3408464089, 3374975369, 1282036360, 1290457736,
51 | 1290400440, 1290314408, 1281925800, 1277727404, 1277792932, 1278785460, 1561962388, 1426698196,
52 | 3607924711, 4131892839, 4140215815, 4292259591, 3218515717, 3209938229, 3171964197, 3171956013,
53 | 4229117295, 4229312879, 4242407935, 4240114111, 4239987133, 4239990013, 3703060732, 1547188252,
54 | 1278748677, 1278748935, 1144662786, 1148854786, 1090388802, 1090388962, 1086260130, 1085940098,
55 | 1102709122, 45811586, 44634002, 44596656, 44592544, 1122527648, 1109944736, 1109977504, 1111030243,
56 | 1111017762, 1109969186, 1126721826, 1101556002, 1084844322, 1084979506, 1084914450, 1084914449,
57 | 1084873520, 3228093296, 3224996817, 3225062275, 3241840002, 3346701698, 3349843394, 3349782306,
58 | 3349719842, 3353914146, 3328748322, 3328747810, 3328809266, 3471476754, 3472530451, 3472473123,
59 | 3472417825, 3395841056, 3458735136, 3341420624, 1076496560, 1076501168, 1076501136, 1076497024
60 | };
61 |
62 | var actual = FFmpegWrapper.Fingerprint(
63 | queueEpisode("audio/big_buck_bunny_intro.mp3"),
64 | AnalysisMode.Introduction);
65 |
66 | Assert.Equal(expected, actual);
67 | }
68 |
69 | [Fact]
70 | public void TestIndexGeneration()
71 | {
72 | // 0 1 2 3 4 5 6 7
73 | var fpr = new uint[] { 1, 2, 3, 1, 5, 77, 42, 2 };
74 | var expected = new Dictionary()
75 | {
76 | {1, 3},
77 | {2, 7},
78 | {3, 2},
79 | {5, 4},
80 | {42, 6},
81 | {77, 5},
82 | };
83 |
84 | var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr);
85 |
86 | Assert.Equal(expected, actual);
87 | }
88 |
89 | [FactSkipFFmpegTests]
90 | public void TestIntroDetection()
91 | {
92 | var chromaprint = CreateChromaprintAnalyzer();
93 |
94 | var lhsEpisode = queueEpisode("audio/big_buck_bunny_intro.mp3");
95 | var rhsEpisode = queueEpisode("audio/big_buck_bunny_clip.mp3");
96 | var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction);
97 | var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction);
98 |
99 | var (lhs, rhs) = chromaprint.CompareEpisodes(
100 | lhsEpisode.EpisodeId,
101 | lhsFingerprint,
102 | rhsEpisode.EpisodeId,
103 | rhsFingerprint);
104 |
105 | Assert.True(lhs.Valid);
106 | Assert.Equal(0, lhs.IntroStart);
107 | Assert.Equal(17.792, lhs.IntroEnd);
108 |
109 | Assert.True(rhs.Valid);
110 | Assert.Equal(5.12, rhs.IntroStart);
111 | Assert.Equal(22.912, rhs.IntroEnd);
112 | }
113 |
114 | ///
115 | /// Test that the silencedetect wrapper is working.
116 | ///
117 | [FactSkipFFmpegTests]
118 | public void TestSilenceDetection()
119 | {
120 | var clip = queueEpisode("audio/big_buck_bunny_clip.mp3");
121 |
122 | var expected = new TimeRange[]
123 | {
124 | new TimeRange(44.6310, 44.8072),
125 | new TimeRange(53.5905, 53.8070),
126 | new TimeRange(53.8458, 54.2024),
127 | new TimeRange(54.2611, 54.5935),
128 | new TimeRange(54.7098, 54.9293),
129 | new TimeRange(54.9294, 55.2590),
130 | };
131 |
132 | var actual = FFmpegWrapper.DetectSilence(clip, 60);
133 |
134 | Assert.Equal(expected, actual);
135 | }
136 |
137 | private QueuedEpisode queueEpisode(string path)
138 | {
139 | return new QueuedEpisode()
140 | {
141 | EpisodeId = Guid.NewGuid(),
142 | Path = "../../../" + path,
143 | IntroFingerprintEnd = 60
144 | };
145 | }
146 |
147 | private ChromaprintAnalyzer CreateChromaprintAnalyzer()
148 | {
149 | var logger = new LoggerFactory().CreateLogger();
150 | return new(logger);
151 | }
152 | }
153 |
154 | public class FactSkipFFmpegTests : FactAttribute
155 | {
156 | #if SKIP_FFMPEG_TESTS
157 | public FactSkipFFmpegTests() {
158 | Skip = "SKIP_FFMPEG_TESTS defined, skipping unit tests that require FFmpeg to be installed";
159 | }
160 | #endif
161 | }
162 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
2 |
3 | using System;
4 | using System.Collections.Generic;
5 | using Microsoft.Extensions.Logging;
6 | using Xunit;
7 |
8 | public class TestBlackFrames
9 | {
10 | [FactSkipFFmpegTests]
11 | public void TestBlackFrameDetection()
12 | {
13 | var range = 1e-5;
14 |
15 | var expected = new List();
16 | expected.AddRange(CreateFrameSequence(2, 3));
17 | expected.AddRange(CreateFrameSequence(5, 6));
18 | expected.AddRange(CreateFrameSequence(8, 9.96));
19 |
20 | var actual = FFmpegWrapper.DetectBlackFrames(queueFile("rainbow.mp4"), new(0, 10), 85);
21 |
22 | for (var i = 0; i < expected.Count; i++)
23 | {
24 | var (e, a) = (expected[i], actual[i]);
25 | Assert.Equal(e.Percentage, a.Percentage);
26 | Assert.InRange(a.Time, e.Time - range, e.Time + range);
27 | }
28 | }
29 |
30 | [FactSkipFFmpegTests]
31 | public void TestEndCreditDetection()
32 | {
33 | var range = 1;
34 |
35 | var analyzer = CreateBlackFrameAnalyzer();
36 |
37 | var episode = queueFile("credits.mp4");
38 | episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds;
39 |
40 | var result = analyzer.AnalyzeMediaFile(episode, AnalysisMode.Credits, 85);
41 | Assert.NotNull(result);
42 | Assert.InRange(result.IntroStart, 300 - range, 300 + range);
43 | }
44 |
45 | private QueuedEpisode queueFile(string path)
46 | {
47 | return new()
48 | {
49 | EpisodeId = Guid.NewGuid(),
50 | Name = path,
51 | Path = "../../../video/" + path
52 | };
53 | }
54 |
55 | private BlackFrame[] CreateFrameSequence(double start, double end)
56 | {
57 | var frames = new List();
58 |
59 | for (var i = start; i < end; i += 0.04)
60 | {
61 | frames.Add(new(100, i));
62 | }
63 |
64 | return frames.ToArray();
65 | }
66 |
67 | private BlackFrameAnalyzer CreateBlackFrameAnalyzer()
68 | {
69 | var logger = new LoggerFactory().CreateLogger();
70 | return new(logger);
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
2 |
3 | using System;
4 | using System.Collections.Generic;
5 | using System.Collections.ObjectModel;
6 | using MediaBrowser.Model.Entities;
7 | using Microsoft.Extensions.Logging;
8 | using Xunit;
9 |
10 | public class TestChapterAnalyzer
11 | {
12 | [Theory]
13 | [InlineData("Opening")]
14 | [InlineData("OP")]
15 | [InlineData("Intro")]
16 | [InlineData("Intro Start")]
17 | [InlineData("Introduction")]
18 | public void TestIntroductionExpression(string chapterName)
19 | {
20 | var chapters = CreateChapters(chapterName, AnalysisMode.Introduction);
21 | var introChapter = FindChapter(chapters, AnalysisMode.Introduction);
22 |
23 | Assert.NotNull(introChapter);
24 | Assert.Equal(60, introChapter.IntroStart);
25 | Assert.Equal(90, introChapter.IntroEnd);
26 | }
27 |
28 | [Theory]
29 | [InlineData("End Credits")]
30 | [InlineData("Ending")]
31 | [InlineData("Credit start")]
32 | [InlineData("Closing Credits")]
33 | [InlineData("Credits")]
34 | public void TestEndCreditsExpression(string chapterName)
35 | {
36 | var chapters = CreateChapters(chapterName, AnalysisMode.Credits);
37 | var creditsChapter = FindChapter(chapters, AnalysisMode.Credits);
38 |
39 | Assert.NotNull(creditsChapter);
40 | Assert.Equal(1890, creditsChapter.IntroStart);
41 | Assert.Equal(2000, creditsChapter.IntroEnd);
42 | }
43 |
44 | private Intro? FindChapter(Collection chapters, AnalysisMode mode)
45 | {
46 | var logger = new LoggerFactory().CreateLogger();
47 | var analyzer = new ChapterAnalyzer(logger);
48 |
49 | var config = new Configuration.PluginConfiguration();
50 | var expression = mode == AnalysisMode.Introduction ?
51 | config.ChapterAnalyzerIntroductionPattern :
52 | config.ChapterAnalyzerEndCreditsPattern;
53 |
54 | return analyzer.FindMatchingChapter(new() { Duration = 2000 }, chapters, expression, mode);
55 | }
56 |
57 | private Collection CreateChapters(string name, AnalysisMode mode)
58 | {
59 | var chapters = new[]{
60 | CreateChapter("Cold Open", 0),
61 | CreateChapter(mode == AnalysisMode.Introduction ? name : "Introduction", 60),
62 | CreateChapter("Main Episode", 90),
63 | CreateChapter(mode == AnalysisMode.Credits ? name : "Credits", 1890)
64 | };
65 |
66 | return new(new List(chapters));
67 | }
68 |
69 | ///
70 | /// Create a ChapterInfo object.
71 | ///
72 | /// Chapter name.
73 | /// Chapter position (in seconds).
74 | /// ChapterInfo.
75 | private ChapterInfo CreateChapter(string name, int position)
76 | {
77 | return new()
78 | {
79 | Name = name,
80 | StartPositionTicks = TimeSpan.FromSeconds(position).Ticks
81 | };
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestContiguous.cs:
--------------------------------------------------------------------------------
1 | using Xunit;
2 |
3 | namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
4 |
5 | public class TestTimeRanges
6 | {
7 | [Fact]
8 | public void TestSmallRange()
9 | {
10 | var times = new double[]{
11 | 1, 1.5, 2, 2.5, 3, 3.5, 4,
12 | 100, 100.5, 101, 101.5
13 | };
14 |
15 | var expected = new TimeRange(1, 4);
16 | var actual = TimeRangeHelpers.FindContiguous(times, 2);
17 |
18 | Assert.Equal(expected, actual);
19 | }
20 |
21 | [Fact]
22 | public void TestLargeRange()
23 | {
24 | var times = new double[]{
25 | 1, 1.5, 2,
26 | 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,
27 | 4.5, 5.3122, 5.3123, 5.3124, 5.3125, 5.3126, 5.3127, 5.3128,
28 | 55, 55.5, 55.6, 55.7
29 | };
30 |
31 | var expected = new TimeRange(1, 5.3128);
32 | var actual = TimeRangeHelpers.FindContiguous(times, 2);
33 |
34 | Assert.Equal(expected, actual);
35 | }
36 |
37 | [Fact]
38 | public void TestFuturama()
39 | {
40 | // These timestamps were manually extracted from Futurama S01E04 and S01E05.
41 | var times = new double[]{
42 | 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,
43 | 17.408, 17.536, 17.664, 17.792, 17.92, 18.048, 18.176, 18.304, 18.432, 18.56, 18.688, 18.816,
44 | 18.944, 19.072, 19.2, 19.328, 19.456, 19.584, 19.712, 19.84, 19.968, 20.096, 20.224, 20.352,
45 | 20.48, 20.608, 20.736, 20.864, 20.992, 21.12, 21.248, 21.376, 21.504, 21.632, 21.76, 21.888,
46 | 22.016, 22.144, 22.272, 22.4, 22.528, 22.656, 22.784, 22.912, 23.04, 23.168, 23.296, 23.424,
47 | 23.552, 23.68, 23.808, 23.936, 24.064, 24.192, 24.32, 24.448, 24.576, 24.704, 24.832, 24.96,
48 | 25.088, 25.216, 25.344, 25.472, 25.6, 25.728, 25.856, 25.984, 26.112, 26.24, 26.368, 26.496,
49 | 26.624, 26.752, 26.88, 27.008, 27.136, 27.264, 27.392, 27.52, 27.648, 27.776, 27.904, 28.032,
50 | 28.16, 28.288, 28.416, 28.544, 28.672, 28.8, 28.928, 29.056, 29.184, 29.312, 29.44, 29.568,
51 | 29.696, 29.824, 29.952, 30.08, 30.208, 30.336, 30.464, 30.592, 30.72, 30.848, 30.976, 31.104,
52 | 31.232, 31.36, 31.488, 31.616, 31.744, 31.872, 32, 32.128, 32.256, 32.384, 32.512, 32.64,
53 | 32.768, 32.896, 33.024, 33.152, 33.28, 33.408, 33.536, 33.664, 33.792, 33.92, 34.048, 34.176,
54 | 34.304, 34.432, 34.56, 34.688, 34.816, 34.944, 35.072, 35.2, 35.328, 35.456, 35.584, 35.712,
55 | 35.84, 35.968, 36.096, 36.224, 36.352, 36.48, 36.608, 36.736, 36.864, 36.992, 37.12, 37.248,
56 | 37.376, 37.504, 37.632, 37.76, 37.888, 38.016, 38.144, 38.272, 38.4, 38.528, 38.656, 38.784,
57 | 38.912, 39.04, 39.168, 39.296, 39.424, 39.552, 39.68, 39.808, 39.936, 40.064, 40.192, 40.32,
58 | 40.448, 40.576, 40.704, 40.832, 40.96, 41.088, 41.216, 41.344, 41.472, 41.6, 41.728, 41.856,
59 | 41.984, 42.112, 42.24, 42.368, 42.496, 42.624, 42.752, 42.88, 43.008, 43.136, 43.264, 43.392,
60 | 43.52, 43.648, 43.776, 43.904, 44.032, 44.16, 44.288, 44.416, 44.544, 44.672, 44.8, 44.928,
61 | 45.056, 45.184, 57.344, 62.976, 68.864, 74.368, 81.92, 82.048, 86.528, 100.864, 102.656,
62 | 102.784, 102.912, 103.808, 110.976, 116.864, 125.696, 128.384, 133.248, 133.376, 136.064,
63 | 136.704, 142.976, 150.272, 152.064, 164.864, 164.992, 166.144, 166.272, 175.488, 190.08,
64 | 191.872, 192, 193.28, 193.536, 213.376, 213.504, 225.664, 225.792, 243.2, 243.84, 256,
65 | 264.448, 264.576, 264.704, 269.568, 274.816, 274.944, 276.096, 283.264, 294.784, 294.912,
66 | 295.04, 295.168, 313.984, 325.504, 333.568, 335.872, 336.384
67 | };
68 |
69 | var expected = new TimeRange(16, 45.184);
70 | var actual = TimeRangeHelpers.FindContiguous(times, 2);
71 |
72 | Assert.Equal(expected, actual);
73 | }
74 |
75 | ///
76 | /// Tests that TimeRange intersections are detected correctly.
77 | /// Tests each time range against a range of 5 to 10 seconds.
78 | ///
79 | [Theory]
80 | [InlineData(1, 4, false)] // too early
81 | [InlineData(4, 6, true)] // intersects on the left
82 | [InlineData(7, 8, true)] // in the middle
83 | [InlineData(9, 12, true)] // intersects on the right
84 | [InlineData(13, 15, false)] // too late
85 | public void TestTimeRangeIntersection(int start, int end, bool expected)
86 | {
87 | var large = new TimeRange(5, 10);
88 | var testRange = new TimeRange(start, end);
89 |
90 | Assert.Equal(expected, large.Intersects(testRange));
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Xunit;
3 |
4 | namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
5 |
6 | public class TestEdl
7 | {
8 | // Test data is from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL
9 | [Theory]
10 | [InlineData(5.3, 7.1, EdlAction.Cut, "5.3 7.1 0")]
11 | [InlineData(15, 16.7, EdlAction.Mute, "15 16.7 1")]
12 | [InlineData(420, 822, EdlAction.CommercialBreak, "420 822 3")]
13 | [InlineData(1, 255.3, EdlAction.SceneMarker, "1 255.3 2")]
14 | [InlineData(1.123456789, 5.654647987, EdlAction.CommercialBreak, "1.12 5.65 3")]
15 | public void TestEdlSerialization(double start, double end, EdlAction action, string expected)
16 | {
17 | var intro = MakeIntro(start, end);
18 | var actual = intro.ToEdl(action);
19 |
20 | Assert.Equal(expected, actual);
21 | }
22 |
23 | [Fact]
24 | public void TestEdlInvalidSerialization()
25 | {
26 | Assert.Throws(() => {
27 | var intro = MakeIntro(0, 5);
28 | intro.ToEdl(EdlAction.None);
29 | });
30 | }
31 |
32 | [Theory]
33 | [InlineData("Death Note - S01E12 - Love.mkv", "Death Note - S01E12 - Love.edl")]
34 | [InlineData("/full/path/to/file.rm", "/full/path/to/file.edl")]
35 | public void TestEdlPath(string mediaPath, string edlPath)
36 | {
37 | Assert.Equal(edlPath, EdlManager.GetEdlPath(mediaPath));
38 | }
39 |
40 | private Intro MakeIntro(double start, double end)
41 | {
42 | return new Intro(Guid.Empty, new TimeRange(start, end));
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestWarnings.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
2 |
3 | using Xunit;
4 |
5 | public class TestFlags
6 | {
7 | [Fact]
8 | public void TestEmptyFlagSerialization()
9 | {
10 | WarningManager.Clear();
11 | Assert.Equal("None", WarningManager.GetWarnings());
12 | }
13 |
14 | [Fact]
15 | public void TestSingleFlagSerialization()
16 | {
17 | WarningManager.Clear();
18 | WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
19 | Assert.Equal("UnableToAddSkipButton", WarningManager.GetWarnings());
20 | }
21 |
22 | [Fact]
23 | public void TestDoubleFlagSerialization()
24 | {
25 | WarningManager.Clear();
26 | WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
27 | WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
28 | WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
29 |
30 | Assert.Equal(
31 | "UnableToAddSkipButton, InvalidChromaprintFingerprint",
32 | WarningManager.GetWarnings());
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/README.txt:
--------------------------------------------------------------------------------
1 | The audio used in the fingerprinting unit tests is from Big Buck Bunny, attributed below.
2 |
3 | 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.
4 |
5 | Both files have been downmixed to two audio channels.
6 | big_buck_bunny_intro.mp3 is from 5 to 30 seconds.
7 | big_buck_bunny_clip.mp3 is from 0 to 60 seconds.
8 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/big_buck_bunny_clip.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/686c978a2f5f57a909dc6e76029a0c225e2e3ec4/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/big_buck_bunny_clip.mp3
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/big_buck_bunny_intro.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/686c978a2f5f57a909dc6e76029a0c225e2e3ec4/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/big_buck_bunny_intro.mp3
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries
2 | /verifier/verifier
3 | /run_tests
4 | /plugin_binaries/
5 |
6 | # Wrapper configuration and base configuration files
7 | config.json
8 | /config/
9 |
10 | # Timestamp reports
11 | /reports/
12 |
13 | # Selenium screenshots
14 | selenium/screenshots/
15 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/README.md:
--------------------------------------------------------------------------------
1 | # End to end testing framework
2 |
3 | ## wrapper
4 |
5 | The wrapper script (compiled as `run_tests`) runs multiple tests on Jellyfin servers to verify that the plugin works as intended. It tests:
6 |
7 | - Introduction timestamp accuracy (using `verifier`)
8 | - Web interface functionality (using `selenium/main.py`)
9 |
10 | ## verifier
11 |
12 | ### Description
13 |
14 | This program is responsible for:
15 | * Saving all discovered introduction timestamps into a report
16 | * Comparing two reports against each other to find episodes that:
17 | * Are missing introductions in both reports
18 | * Have introductions in both reports, but with different timestamps
19 | * Newly discovered introductions
20 | * Introductions that were discovered previously, but not anymore
21 | * Validating the schema of returned `Intro` objects from the `/IntroTimestamps` API endpoint
22 |
23 | ### Usage examples
24 | * Generate intro timestamp report from a local server:
25 | * `./verifier -address http://127.0.0.1:8096 -key api_key`
26 | * Generate intro timestamp report from a remote server, polling for task completion every 20 seconds:
27 | * `./verifier -address https://example.com -key api_key -poll 20s -o example.json`
28 | * Compare two previously generated reports:
29 | * `./verifier -r1 v0.1.5.json -r2 v0.1.6.json`
30 | * Validate the API schema for three episodes:
31 | * `./verifier -address http://127.0.0.1:8096 -key api_key -validate id1,id2,id3`
32 |
33 | ## Selenium web interface tests
34 |
35 | Selenium is used to verify that the plugin's web interface works as expected. It simulates a user:
36 |
37 | * Clicking the skip intro button
38 | * Checks that clicking the button skips the intro and keeps playing the video
39 | * Changing settings (will be added in the future)
40 | * Maximum degree of parallelism
41 | * Selecting libraries for analysis
42 | * EDL settings
43 | * Introduction requirements
44 | * Auto skip
45 | * Show/hide skip prompt
46 | * Timestamp editor (will be added in the future)
47 | * Displays timestamps
48 | * Modifies timestamps
49 | * Erases season timestamps
50 | * Fingerprint visualizer (will be added in the future)
51 | * Suggests shifts
52 | * Visualizer canvas is drawn on
53 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | echo "[+] Building timestamp verifier"
4 | (cd verifier && go build -o verifier) || exit 1
5 |
6 | echo "[+] Building test wrapper"
7 | (cd wrapper && go test ./... && go build -o ../run_tests) || exit 1
8 |
9 | echo
10 | echo "[+] All programs built successfully"
11 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/config_sample.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "library": "/full/path/to/test/library/on/host/TV",
4 | "episode": "Episode title to search for"
5 | },
6 | "servers": [
7 | {
8 | "comment": "Optional comment to identify this server",
9 | "image": "ghcr.io/confusedpolarbear/jellyfin-intro-skipper:latest",
10 | "username": "admin",
11 | "password": "hunter2",
12 | "browsers": [
13 | "chrome",
14 | "firefox"
15 | ], // supported values are "chrome" and "firefox".
16 | "tests": [
17 | "skip_button", // test skip intro button
18 | "settings" // test plugin administration page
19 | ]
20 | }
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | chrome:
4 | image: selenium/standalone-chrome:106.0
5 | shm_size: 2gb
6 | ports:
7 | - 4444:4444
8 | environment:
9 | - SE_NODE_SESSION_TIMEOUT=10
10 |
11 | firefox:
12 | image: selenium/standalone-firefox:105.0
13 | shm_size: 2gb
14 | ports:
15 | - 4445:4444
16 | environment:
17 | - SE_NODE_SESSION_TIMEOUT=10
18 |
19 | chrome_video:
20 | image: selenium/video
21 | environment:
22 | - DISPLAY_CONTAINER_NAME=chrome
23 | - FILE_NAME=chrome_video.mp4
24 | volumes:
25 | - /tmp/selenium/videos:/videos
26 |
27 | firefox_video:
28 | image: selenium/video
29 | environment:
30 | - DISPLAY_CONTAINER_NAME=firefox
31 | - FILE_NAME=firefox_video.mp4
32 | volumes:
33 | - /tmp/selenium/videos:/videos
34 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/reports/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/686c978a2f5f57a909dc6e76029a0c225e2e3ec4/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/reports/.keep
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/selenium/main.py:
--------------------------------------------------------------------------------
1 | import argparse, os, time
2 |
3 | from selenium import webdriver
4 | from selenium.webdriver.common.by import By
5 | from selenium.webdriver.common.keys import Keys
6 |
7 |
8 | # Driver function
9 | def main():
10 | # Parse CLI arguments and store in a dictionary
11 | parser = argparse.ArgumentParser()
12 | parser.add_argument("-host", help="Jellyfin server address with protocol and port.")
13 | parser.add_argument("-username", help="Username.")
14 | parser.add_argument("-password", help="Password.")
15 | parser.add_argument("-name", help="Name of episode to search for.")
16 | parser.add_argument(
17 | "--tests", help="Space separated list of Selenium tests to run.", type=str, nargs="+"
18 | )
19 | parser.add_argument(
20 | "--browsers",
21 | help="Space separated list of browsers to run tests with.",
22 | type=str,
23 | nargs="+",
24 | choices=["chrome", "firefox"],
25 | )
26 | args = parser.parse_args()
27 |
28 | server = {
29 | "host": args.host,
30 | "username": args.username,
31 | "password": args.password,
32 | "episode": args.name,
33 | "browsers": args.browsers,
34 | "tests": args.tests,
35 | }
36 |
37 | # Print the server info for debugging and run the test
38 | print()
39 | print(f"Browsers: {server['browsers']}")
40 | print(f"Address: {server['host']}")
41 | print(f"Username: {server['username']}")
42 | print(f"Episode: \"{server['episode']}\"")
43 | print(f"Tests: {server['tests']}")
44 | print()
45 |
46 | # Setup the list of drivers to run tests with
47 | if server["browsers"] is None:
48 | print("[!] --browsers is required")
49 | exit(1)
50 |
51 | drivers = []
52 | if "chrome" in server["browsers"]:
53 | drivers = [("http://127.0.0.1:4444", "Chrome")]
54 | if "firefox" in server["browsers"]:
55 | drivers.append(("http://127.0.0.1:4445", "Firefox"))
56 |
57 | # Test with all selected drivers
58 | for driver in drivers:
59 | print(f"[!] Starting new test run using {driver[1]}")
60 | test_server(server, driver[0], driver[1])
61 | print()
62 |
63 |
64 | # Main server test function
65 | def test_server(server, executor, driver_type):
66 | # Configure Selenium to use a remote driver
67 | print(f"[+] Configuring Selenium to use executor {executor} of type {driver_type}")
68 |
69 | opts = None
70 | if driver_type == "Chrome":
71 | opts = webdriver.ChromeOptions()
72 | elif driver_type == "Firefox":
73 | opts = webdriver.FirefoxOptions()
74 | else:
75 | raise ValueError(f"Unknown driver type {driver_type}")
76 |
77 | driver = webdriver.Remote(command_executor=executor, options=opts)
78 |
79 | try:
80 | # Wait up to two seconds when finding an element before reporting failure
81 | driver.implicitly_wait(2)
82 |
83 | # Login to Jellyfin
84 | driver.get(make_url(server, "/"))
85 |
86 | print(f"[+] Authenticating as {server['username']}")
87 | login(driver, server)
88 |
89 | if "skip_button" in server["tests"]:
90 | # Play the user specified episode and verify skip intro button functionality. This episode is expected to:
91 | # * already have been analyzed for an introduction
92 | # * have an introduction at the beginning of the episode
93 | print("[+] Testing skip intro button")
94 | test_skip_button(driver, server)
95 |
96 | print("[+] All tests completed successfully")
97 | finally:
98 | # Unconditionally end the Selenium session
99 | driver.quit()
100 |
101 |
102 | def login(driver, server):
103 | # Append the Enter key to the password to submit the form
104 | us = server["username"]
105 | pw = server["password"] + Keys.ENTER
106 |
107 | # Fill out and submit the login form
108 | driver.find_element(By.ID, "txtManualName").send_keys(us)
109 | driver.find_element(By.ID, "txtManualPassword").send_keys(pw)
110 |
111 |
112 | def test_skip_button(driver, server):
113 | print(f" [+] Searching for episode \"{server['episode']}\"")
114 |
115 | search = driver.find_element(By.CSS_SELECTOR, ".headerSearchButton span.search")
116 |
117 | if driver.capabilities["browserName"] == "firefox":
118 | # Work around a FF bug where the search element isn't considered clickable right away
119 | time.sleep(1)
120 |
121 | # Click the search button
122 | search.click()
123 |
124 | # Type the episode name
125 | driver.find_element(By.CSS_SELECTOR, ".searchfields-txtSearch").send_keys(
126 | server["episode"]
127 | )
128 |
129 | # Click the first episode in the search results
130 | driver.find_element(
131 | By.CSS_SELECTOR, ".searchResults button[data-type='Episode']"
132 | ).click()
133 |
134 | # Wait for the episode page to finish loading by searching for the episode description (overview)
135 | driver.find_element(By.CSS_SELECTOR, ".overview")
136 |
137 | print(f" [+] Waiting for playback to start")
138 |
139 | # Click the play button in the toolbar
140 | driver.find_element(
141 | By.CSS_SELECTOR, "div.mainDetailButtons span.play_arrow"
142 | ).click()
143 |
144 | # Wait for playback to start by searching for the lower OSD control bar
145 | driver.find_element(By.CSS_SELECTOR, ".osdControls")
146 |
147 | # Let the video play a little bit so the position before clicking the button can be logged
148 | print(" [+] Playing video")
149 | time.sleep(2)
150 | screenshot(driver, "skip_button_pre_skip")
151 | assert_video_playing(driver)
152 |
153 | # Find the skip intro button and click it, logging the new video position after the seek is preformed
154 | print(" [+] Clicking skip intro button")
155 | driver.find_element(By.CSS_SELECTOR, "div#skipIntro").click()
156 | time.sleep(1)
157 | screenshot(driver, "skip_button_post_skip")
158 | assert_video_playing(driver)
159 |
160 | # Keep playing the video for a few seconds to ensure that:
161 | # * the intro was successfully skipped
162 | # * video playback continued automatically post button click
163 | print(" [+] Verifying post skip position")
164 | time.sleep(4)
165 |
166 | screenshot(driver, "skip_button_post_play")
167 | assert_video_playing(driver)
168 |
169 |
170 | # Utility functions
171 | def make_url(server, url):
172 | final = server["host"] + url
173 | print(f"[+] Navigating to {final}")
174 | return final
175 |
176 |
177 | def screenshot(driver, filename):
178 | dest = f"screenshots/{filename}.png"
179 | driver.save_screenshot(dest)
180 |
181 |
182 | # Returns the current video playback position and if the video is paused.
183 | # Will raise an exception if playback is paused as the video shouldn't ever pause when using this plugin.
184 | def assert_video_playing(driver):
185 | ret = driver.execute_script(
186 | """
187 | const video = document.querySelector("video");
188 | return {
189 | "position": video.currentTime,
190 | "paused": video.paused
191 | };
192 | """
193 | )
194 |
195 | if ret["paused"]:
196 | raise Exception("Video should not be paused")
197 |
198 | print(f" [+] Video playback position: {ret['position']}")
199 |
200 | return ret
201 |
202 |
203 | main()
204 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/selenium/requirements.txt:
--------------------------------------------------------------------------------
1 | selenium >= 4.3.0
2 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/selenium/screenshots/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/686c978a2f5f57a909dc6e76029a0c225e2e3ec4/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/selenium/screenshots/.keep
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/confusedpolarbear/intro_skipper_verifier
2 |
3 | go 1.17
4 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/http.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "strings"
9 | "time"
10 |
11 | "github.com/confusedpolarbear/intro_skipper_verifier/structs"
12 | )
13 |
14 | // Gets the contents of the provided URL or panics.
15 | func SendRequest(method, url, apiKey string) []byte {
16 | http.DefaultClient.Timeout = 10 * time.Second
17 |
18 | // Construct the request
19 | req, err := http.NewRequest(method, url, nil)
20 | if err != nil {
21 | panic(err)
22 | }
23 |
24 | // Include the authorization token
25 | req.Header.Set("Authorization", fmt.Sprintf(`MediaBrowser Token="%s"`, apiKey))
26 |
27 | // Send the request
28 | res, err := http.DefaultClient.Do(req)
29 |
30 | if !strings.Contains(url, "hideUrl") {
31 | fmt.Printf("[+] %s %s: %d\n", method, url, res.StatusCode)
32 | }
33 |
34 | // Panic if any error occurred
35 | if err != nil {
36 | panic(err)
37 | }
38 |
39 | // Check for API key validity
40 | if res.StatusCode == http.StatusUnauthorized {
41 | panic("Server returned 401 (Unauthorized). Check API key validity and try again.")
42 | }
43 |
44 | // Read and return the entire body
45 | defer res.Body.Close()
46 | body, err := io.ReadAll(res.Body)
47 | if err != nil {
48 | panic(err)
49 | }
50 |
51 | return body
52 | }
53 |
54 | func GetServerInfo(hostAddress, apiKey string) structs.PublicInfo {
55 | var info structs.PublicInfo
56 |
57 | fmt.Println("[+] Getting server information")
58 | rawInfo := SendRequest("GET", hostAddress+"/System/Info/Public", apiKey)
59 |
60 | if err := json.Unmarshal(rawInfo, &info); err != nil {
61 | panic(err)
62 | }
63 |
64 | return info
65 | }
66 |
67 | func GetPluginConfiguration(hostAddress, apiKey string) structs.PluginConfiguration {
68 | var config structs.PluginConfiguration
69 |
70 | fmt.Println("[+] Getting plugin configuration")
71 | rawConfig := SendRequest("GET", hostAddress+"/Plugins/c83d86bb-a1e0-4c35-a113-e2101cf4ee6b/Configuration", apiKey)
72 |
73 | if err := json.Unmarshal(rawConfig, &config); err != nil {
74 | panic(err)
75 | }
76 |
77 | return config
78 | }
79 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "time"
6 | )
7 |
8 | func flags() {
9 | // Report generation
10 | hostAddress := flag.String("address", "", "Address of Jellyfin server to extract intro information from.")
11 | apiKey := flag.String("key", "", "Administrator API key to authenticate with.")
12 | keepTimestamps := flag.Bool("keep", false, "Keep the current timestamps instead of erasing and reanalyzing.")
13 | pollInterval := flag.Duration("poll", 10*time.Second, "Interval to poll task completion at.")
14 | reportDestination := flag.String("o", "", "Report destination filename. Defaults to intros-ADDRESS-TIMESTAMP.json.")
15 |
16 | // Report comparison
17 | report1 := flag.String("r1", "", "First report.")
18 | report2 := flag.String("r2", "", "Second report.")
19 |
20 | // API schema validator
21 | ids := flag.String("validate", "", "Comma separated item ids to validate the API schema for.")
22 |
23 | // Print usage examples
24 | flag.CommandLine.Usage = func() {
25 | flag.CommandLine.Output().Write([]byte("Flags:\n"))
26 | flag.PrintDefaults()
27 |
28 | usage := "\nUsage:\n" +
29 | "Generate intro timestamp report from a local server:\n" +
30 | "./verifier -address http://127.0.0.1:8096 -key api_key\n\n" +
31 |
32 | "Generate intro timestamp report from a remote server, polling for task completion every 20 seconds:\n" +
33 | "./verifier -address https://example.com -key api_key -poll 20s -o example.json\n\n" +
34 |
35 | "Compare two previously generated reports:\n" +
36 | "./verifier -r1 v0.1.5.json -r2 v0.1.6.json\n\n" +
37 |
38 | "Validate the API schema for some item ids:\n" +
39 | "./verifier -address http://127.0.0.1:8096 -key api_key -validate id1,id2,id3\n"
40 |
41 | flag.CommandLine.Output().Write([]byte(usage))
42 | }
43 |
44 | flag.Parse()
45 |
46 | if *hostAddress != "" && *apiKey != "" {
47 | if *ids == "" {
48 | generateReport(*hostAddress, *apiKey, *reportDestination, *keepTimestamps, *pollInterval)
49 | } else {
50 | validateApiSchema(*hostAddress, *apiKey, *ids)
51 | }
52 |
53 | } else if *report1 != "" && *report2 != "" {
54 | compareReports(*report1, *report2, *reportDestination)
55 |
56 | } else {
57 | panic("Either (-address and -key) or (-r1 and -r2) are required.")
58 | }
59 | }
60 |
61 | func main() {
62 | flags()
63 | }
64 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_comparison.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | _ "embed"
5 | "encoding/json"
6 | "fmt"
7 | "html/template"
8 | "math"
9 | "os"
10 | "time"
11 |
12 | "github.com/confusedpolarbear/intro_skipper_verifier/structs"
13 | )
14 |
15 | //go:embed report.html
16 | var reportTemplate []byte
17 |
18 | func compareReports(oldReportPath, newReportPath, destination string) {
19 | start := time.Now()
20 |
21 | // Populate the destination filename if none was provided
22 | if destination == "" {
23 | destination = fmt.Sprintf("report-%d.html", start.Unix())
24 | }
25 |
26 | // Open the report for writing
27 | f, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
28 | if err != nil {
29 | panic(err)
30 | } else {
31 | defer f.Close()
32 | }
33 |
34 | fmt.Printf("Started at: %s\n", start.Format(time.RFC1123))
35 | fmt.Printf("First report: %s\n", oldReportPath)
36 | fmt.Printf("Second report: %s\n", newReportPath)
37 | fmt.Printf("Destination: %s\n\n", destination)
38 |
39 | // Unmarshal both reports
40 | oldReport, newReport := unmarshalReport(oldReportPath), unmarshalReport(newReportPath)
41 |
42 | fmt.Println("[+] Comparing reports")
43 |
44 | // Setup a function map with helper functions to use in the template
45 | tmp := template.New("report")
46 |
47 | funcs := make(template.FuncMap)
48 |
49 | funcs["printTime"] = func(t time.Time) string {
50 | return t.Format(time.RFC1123)
51 | }
52 |
53 | funcs["printDuration"] = func(d time.Duration) string {
54 | return d.Round(time.Second).String()
55 | }
56 |
57 | funcs["printAnalysisSettings"] = func(pc structs.PluginConfiguration) string {
58 | return pc.AnalysisSettings()
59 | }
60 |
61 | funcs["printIntroductionReqs"] = func(pc structs.PluginConfiguration) string {
62 | return pc.IntroductionRequirements()
63 | }
64 |
65 | funcs["sortShows"] = templateSortShows
66 | funcs["sortSeasons"] = templateSortSeason
67 | funcs["compareEpisodes"] = templateCompareEpisodes
68 | tmp.Funcs(funcs)
69 |
70 | // Load the template or panic
71 | report := template.Must(tmp.Parse(string(reportTemplate)))
72 |
73 | err = report.Execute(f,
74 | structs.TemplateReportData{
75 | OldReport: oldReport,
76 | NewReport: newReport,
77 | })
78 |
79 | if err != nil {
80 | panic(err)
81 | }
82 |
83 | // Log success
84 | fmt.Printf("[+] Reports successfully compared in %s\n", time.Since(start).Round(time.Millisecond))
85 | }
86 |
87 | func unmarshalReport(path string) structs.Report {
88 | // Read the provided report
89 | contents, err := os.ReadFile(path)
90 | if err != nil {
91 | panic(err)
92 | }
93 |
94 | // Unmarshal
95 | var report structs.Report
96 | if err := json.Unmarshal(contents, &report); err != nil {
97 | panic(err)
98 | }
99 |
100 | // Setup maps and template data for later use
101 | report.Path = path
102 | report.Shows = make(map[string]structs.Seasons)
103 | report.IntroMap = make(map[string]structs.Intro)
104 |
105 | // Sort episodes by show and season
106 | for _, intro := range report.Intros {
107 | // Round the duration to the nearest second to avoid showing 8 decimal places in the report
108 | intro.Duration = float32(math.Round(float64(intro.Duration)))
109 |
110 | // Pretty print the intro start and end times
111 | intro.FormattedStart = (time.Duration(intro.IntroStart) * time.Second).String()
112 | intro.FormattedEnd = (time.Duration(intro.IntroEnd) * time.Second).String()
113 |
114 | show, season := intro.Series, intro.Season
115 |
116 | // If this show hasn't been seen before, allocate space for it
117 | if _, ok := report.Shows[show]; !ok {
118 | report.Shows[show] = make(structs.Seasons)
119 | }
120 |
121 | // Store this intro in the season of this show
122 | episodes := report.Shows[show][season]
123 | episodes = append(episodes, intro)
124 | report.Shows[show][season] = episodes
125 |
126 | // Store a reference to this intro in a lookup table
127 | report.IntroMap[intro.EpisodeId] = intro
128 | }
129 |
130 | // Print report info
131 | fmt.Printf("Report %s:\n", path)
132 | fmt.Printf("Generated with Jellyfin %s running on %s\n", report.ServerInfo.Version, report.ServerInfo.OperatingSystem)
133 | fmt.Printf("Analysis settings: %s\n", report.PluginConfig.AnalysisSettings())
134 | fmt.Printf("Introduction reqs: %s\n", report.PluginConfig.IntroductionRequirements())
135 | fmt.Printf("Episodes analyzed: %d\n", len(report.Intros))
136 | fmt.Println()
137 |
138 | return report
139 | }
140 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_comparison_util.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "math"
6 | "sort"
7 |
8 | "github.com/confusedpolarbear/intro_skipper_verifier/structs"
9 | )
10 |
11 | // report template helper functions
12 |
13 | // Sort show names alphabetically
14 | func templateSortShows(shows map[string]structs.Seasons) []string {
15 | var showNames []string
16 |
17 | for show := range shows {
18 | showNames = append(showNames, show)
19 | }
20 |
21 | sort.Strings(showNames)
22 |
23 | return showNames
24 | }
25 |
26 | // Sort season numbers
27 | func templateSortSeason(show structs.Seasons) []int {
28 | var keys []int
29 |
30 | for season := range show {
31 | keys = append(keys, season)
32 | }
33 |
34 | sort.Ints(keys)
35 |
36 | return keys
37 | }
38 |
39 | // Compare the episode with the provided ID in the old report to the episode in the new report.
40 | func templateCompareEpisodes(id string, reports structs.TemplateReportData) structs.IntroPair {
41 | var pair structs.IntroPair
42 | var tolerance int = 5
43 |
44 | // Locate both episodes
45 | pair.Old = reports.OldReport.IntroMap[id]
46 | pair.New = reports.NewReport.IntroMap[id]
47 |
48 | // Mark the timestamps as similar if they are within a few seconds of each other
49 | similar := func(oldTime, newTime float32) bool {
50 | diff := math.Abs(float64(newTime) - float64(oldTime))
51 | return diff <= float64(tolerance)
52 | }
53 |
54 | if pair.Old.Valid && !pair.New.Valid {
55 | // If an intro was found previously, but not now, flag it
56 | pair.WarningShort = "only_previous"
57 | pair.Warning = "Introduction found in previous report, but not the current one"
58 |
59 | } else if !pair.Old.Valid && pair.New.Valid {
60 | // If an intro was not found previously, but found now, flag it
61 | pair.WarningShort = "improvement"
62 | pair.Warning = "New introduction discovered"
63 |
64 | } else if !pair.Old.Valid && !pair.New.Valid {
65 | // If an intro has never been found for this episode
66 | pair.WarningShort = "missing"
67 | pair.Warning = "No introduction has ever been found for this episode"
68 |
69 | } else if !similar(pair.Old.IntroStart, pair.New.IntroStart) || !similar(pair.Old.IntroEnd, pair.New.IntroEnd) {
70 | // If the intro timestamps are too different, flag it
71 | pair.WarningShort = "different"
72 | pair.Warning = fmt.Sprintf("Timestamps differ by more than %d seconds", tolerance)
73 |
74 | } else {
75 | // No warning was generated
76 | pair.WarningShort = "okay"
77 | pair.Warning = "Okay"
78 | }
79 |
80 | return pair
81 | }
82 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_generator.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "os/exec"
8 | "strings"
9 | "time"
10 |
11 | "github.com/confusedpolarbear/intro_skipper_verifier/structs"
12 | )
13 |
14 | var spinners []string
15 | var spinnerIndex int
16 |
17 | func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamps bool, pollInterval time.Duration) {
18 | start := time.Now()
19 |
20 | // Setup the spinner
21 | spinners = strings.Split("⣷⣯⣟⡿⢿⣻⣽⣾", "")
22 | spinnerIndex = -1 // start the spinner on the first graphic
23 |
24 | // Setup the filename to save intros to
25 | if reportDestination == "" {
26 | reportDestination = fmt.Sprintf("intros-%s-%d.json", hostAddress, time.Now().Unix())
27 | reportDestination = strings.ReplaceAll(reportDestination, "http://", "")
28 | reportDestination = strings.ReplaceAll(reportDestination, "https://", "")
29 | }
30 |
31 | // Ensure the file is writable
32 | if err := os.WriteFile(reportDestination, nil, 0600); err != nil {
33 | panic(err)
34 | }
35 |
36 | fmt.Printf("Started at: %s\n", start.Format(time.RFC1123))
37 | fmt.Printf("Address: %s\n", hostAddress)
38 | fmt.Printf("Destination: %s\n", reportDestination)
39 | fmt.Println()
40 |
41 | // Get Jellyfin server information and plugin configuration
42 | info := GetServerInfo(hostAddress, apiKey)
43 | config := GetPluginConfiguration(hostAddress, apiKey)
44 | fmt.Println()
45 |
46 | fmt.Printf("Jellyfin OS: %s\n", info.OperatingSystem)
47 | fmt.Printf("Jellyfin version: %s\n", info.Version)
48 | fmt.Printf("Analysis settings: %s\n", config.AnalysisSettings())
49 | fmt.Printf("Introduction reqs: %s\n", config.IntroductionRequirements())
50 | fmt.Printf("Erase timestamps: %t\n", !keepTimestamps)
51 | fmt.Println()
52 |
53 | // If not keeping timestamps, run the fingerprint task.
54 | // Otherwise, log that the task isn't being run
55 | if !keepTimestamps {
56 | runAnalysisAndWait(hostAddress, apiKey, pollInterval)
57 | } else {
58 | fmt.Println("[+] Using previously discovered intros")
59 | }
60 | fmt.Println()
61 |
62 | // Save all intros from the server
63 | fmt.Println("[+] Saving intros")
64 |
65 | var report structs.Report
66 | rawIntros := SendRequest("GET", hostAddress+"/Intros/All", apiKey)
67 | if err := json.Unmarshal(rawIntros, &report.Intros); err != nil {
68 | panic(err)
69 | }
70 |
71 | // Calculate the durations of all intros
72 | for i := range report.Intros {
73 | intro := report.Intros[i]
74 | intro.Duration = intro.IntroEnd - intro.IntroStart
75 | report.Intros[i] = intro
76 | }
77 |
78 | fmt.Println()
79 | fmt.Println("[+] Saving report")
80 |
81 | // Store timing data, server information, and plugin configuration
82 | report.StartedAt = start
83 | report.FinishedAt = time.Now()
84 | report.Runtime = report.FinishedAt.Sub(report.StartedAt)
85 | report.ServerInfo = info
86 | report.PluginConfig = config
87 |
88 | // Marshal the report
89 | marshalled, err := json.Marshal(report)
90 | if err != nil {
91 | panic(err)
92 | }
93 |
94 | if err := os.WriteFile(reportDestination, marshalled, 0600); err != nil {
95 | panic(err)
96 | }
97 |
98 | // Change report permissions
99 | exec.Command("chown", "1000:1000", reportDestination).Run()
100 |
101 | fmt.Println("[+] Done")
102 | }
103 |
104 | func runAnalysisAndWait(hostAddress, apiKey string, pollInterval time.Duration) {
105 | var taskId string = ""
106 |
107 | type taskInfo struct {
108 | State string
109 | CurrentProgressPercentage int
110 | }
111 |
112 | fmt.Println("[+] Erasing previously discovered intros")
113 | SendRequest("POST", hostAddress+"/Intros/EraseTimestamps", apiKey)
114 | fmt.Println()
115 |
116 | var taskIds = []string{
117 | "f64d8ad58e3d7b98548e1a07697eb100", // v0.1.8
118 | "8863329048cc357f7dfebf080f2fe204",
119 | "6adda26c5261c40e8fa4a7e7df568be2"}
120 |
121 | fmt.Println("[+] Starting analysis task")
122 | for _, id := range taskIds {
123 | body := SendRequest("POST", hostAddress+"/ScheduledTasks/Running/"+id, apiKey)
124 | fmt.Println()
125 |
126 | // If the scheduled task was found, store the task ID for later
127 | if !strings.Contains(string(body), "Not Found") {
128 | taskId = id
129 | break
130 | }
131 | }
132 |
133 | if taskId == "" {
134 | panic("unable to find scheduled task")
135 | }
136 |
137 | fmt.Println("[+] Waiting for analysis task to complete")
138 | fmt.Print("[+] Episodes analyzed: 0%")
139 |
140 | var info taskInfo // Last known scheduled task state
141 | var lastQuery time.Time // Time the task info was last updated
142 |
143 | for {
144 | time.Sleep(500 * time.Millisecond)
145 |
146 | // Update the spinner
147 | if spinnerIndex++; spinnerIndex >= len(spinners) {
148 | spinnerIndex = 0
149 | }
150 |
151 | fmt.Printf("\r[%s] Episodes analyzed: %d%%", spinners[spinnerIndex], info.CurrentProgressPercentage)
152 |
153 | if info.CurrentProgressPercentage == 100 {
154 | fmt.Printf("\r[+]") // reset the spinner
155 | fmt.Println()
156 | break
157 | }
158 |
159 | // Get the latest task state & unmarshal (only if enough time has passed since the last update)
160 | if time.Since(lastQuery) <= pollInterval {
161 | continue
162 | }
163 |
164 | lastQuery = time.Now()
165 |
166 | raw := SendRequest("GET", hostAddress+"/ScheduledTasks/"+taskId+"?hideUrl=1", apiKey)
167 |
168 | if err := json.Unmarshal(raw, &info); err != nil {
169 | fmt.Printf("[!] Unable to unmarshal response into taskInfo struct: %s\n", err)
170 | fmt.Printf("%s\n", raw)
171 | continue
172 | }
173 |
174 | // Print the latest task state
175 | switch info.State {
176 | case "Idle":
177 | info.CurrentProgressPercentage = 100
178 | }
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/schema_validation.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "strings"
7 | "time"
8 |
9 | "github.com/confusedpolarbear/intro_skipper_verifier/structs"
10 | )
11 |
12 | // Given a comma separated list of item IDs, validate the returned API schema.
13 | func validateApiSchema(hostAddress, apiKey, rawIds string) {
14 | // Iterate over the raw item IDs and validate the schema of API responses
15 | ids := strings.Split(rawIds, ",")
16 |
17 | start := time.Now()
18 |
19 | fmt.Printf("Started at: %s\n", start.Format(time.RFC1123))
20 | fmt.Printf("Address: %s\n", hostAddress)
21 | fmt.Println()
22 |
23 | // Get Jellyfin server information
24 | info := GetServerInfo(hostAddress, apiKey)
25 | fmt.Println()
26 |
27 | fmt.Printf("Jellyfin OS: %s\n", info.OperatingSystem)
28 | fmt.Printf("Jellyfin version: %s\n", info.Version)
29 | fmt.Println()
30 |
31 | for _, id := range ids {
32 | fmt.Printf("[+] Validating item %s\n", id)
33 |
34 | fmt.Println(" [+] Validating API v1 (implicitly versioned)")
35 | intro, schema := getTimestampsV1(hostAddress, apiKey, id, "")
36 | validateV1Intro(id, intro, schema)
37 |
38 | fmt.Println(" [+] Validating API v1 (explicitly versioned)")
39 | intro, schema = getTimestampsV1(hostAddress, apiKey, id, "v1")
40 | validateV1Intro(id, intro, schema)
41 |
42 | fmt.Println()
43 | }
44 |
45 | fmt.Printf("Validated %d items in %s\n", len(ids), time.Since(start).Round(time.Millisecond))
46 | }
47 |
48 | // Validates the returned intro object, panicking on any error.
49 | func validateV1Intro(id string, intro structs.Intro, schema map[string]interface{}) {
50 | // Validate the item ID
51 | if intro.EpisodeId != id {
52 | panic(fmt.Sprintf("Intro struct has incorrect item ID. Expected '%s', found '%s'", id, intro.EpisodeId))
53 | }
54 |
55 | // Validate the intro start and end times
56 | if intro.IntroStart < 0 || intro.IntroEnd < 0 {
57 | panic("Intro struct has a negative intro start or end time")
58 | }
59 |
60 | if intro.ShowSkipPromptAt > intro.IntroStart {
61 | panic("Intro struct show prompt time is after intro start")
62 | }
63 |
64 | if intro.HideSkipPromptAt > intro.IntroEnd {
65 | panic("Intro struct hide prompt time is after intro end")
66 | }
67 |
68 | // Validate the intro duration
69 | if duration := intro.IntroEnd - intro.IntroStart; duration < 15 {
70 | panic(fmt.Sprintf("Intro struct has duration %0.2f but the minimum allowed is 15", duration))
71 | }
72 |
73 | // Ensure the intro is marked as valid.
74 | if !intro.Valid {
75 | panic("Intro struct is not marked as valid")
76 | }
77 |
78 | // Check for any extraneous properties
79 | allowedProperties := []string{"EpisodeId", "Valid", "IntroStart", "IntroEnd", "ShowSkipPromptAt", "HideSkipPromptAt"}
80 |
81 | for schemaKey := range schema {
82 | okay := false
83 |
84 | for _, allowed := range allowedProperties {
85 | if allowed == schemaKey {
86 | okay = true
87 | break
88 | }
89 | }
90 |
91 | if !okay {
92 | panic(fmt.Sprintf("Intro object contains unknown key '%s'", schemaKey))
93 | }
94 | }
95 | }
96 |
97 | // Gets the timestamps for the provided item or panics.
98 | func getTimestampsV1(hostAddress, apiKey, id, version string) (structs.Intro, map[string]interface{}) {
99 | var rawResponse map[string]interface{}
100 | var intro structs.Intro
101 |
102 | // Make an authenticated GET request to {Host}/Episode/{ItemId}/IntroTimestamps/{Version}
103 | raw := SendRequest("GET", fmt.Sprintf("%s/Episode/%s/IntroTimestamps/%s?hideUrl=1", hostAddress, id, version), apiKey)
104 |
105 | // Unmarshal the response as a version 1 API response, ignoring any unknown fields.
106 | if err := json.Unmarshal(raw, &intro); err != nil {
107 | panic(err)
108 | }
109 |
110 | // Second, unmarshal the response into a map so that any unknown fields can be detected and alerted on.
111 | if err := json.Unmarshal(raw, &rawResponse); err != nil {
112 | panic(err)
113 | }
114 |
115 | return intro, rawResponse
116 | }
117 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/intro.go:
--------------------------------------------------------------------------------
1 | package structs
2 |
3 | type Intro struct {
4 | EpisodeId string
5 |
6 | Series string
7 | Season int
8 | Title string
9 |
10 | IntroStart float32
11 | IntroEnd float32
12 | Duration float32
13 | Valid bool
14 |
15 | FormattedStart string
16 | FormattedEnd string
17 |
18 | ShowSkipPromptAt float32
19 | HideSkipPromptAt float32
20 | }
21 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/plugin_configuration.go:
--------------------------------------------------------------------------------
1 | package structs
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | )
7 |
8 | type PluginConfiguration struct {
9 | CacheFingerprints bool
10 | MaxParallelism int
11 | SelectedLibraries string
12 |
13 | AnalysisPercent int
14 | AnalysisLengthLimit int
15 | MinimumIntroDuration int
16 | }
17 |
18 | func (c PluginConfiguration) AnalysisSettings() string {
19 | // If no libraries have been selected, display a star.
20 | // Otherwise, quote each library before displaying the slice.
21 | var libs []string
22 | if c.SelectedLibraries == "" {
23 | libs = []string{"*"}
24 | } else {
25 | for _, tmp := range strings.Split(c.SelectedLibraries, ",") {
26 | tmp = `"` + strings.TrimSpace(tmp) + `"`
27 | libs = append(libs, tmp)
28 | }
29 | }
30 |
31 | return fmt.Sprintf(
32 | "cfp=%t thr=%d lbs=%v",
33 | c.CacheFingerprints,
34 | c.MaxParallelism,
35 | libs)
36 | }
37 |
38 | func (c PluginConfiguration) IntroductionRequirements() string {
39 | return fmt.Sprintf(
40 | "per=%d%% max=%dm min=%ds",
41 | c.AnalysisPercent,
42 | c.AnalysisLengthLimit,
43 | c.MinimumIntroDuration)
44 | }
45 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/public_info.go:
--------------------------------------------------------------------------------
1 | package structs
2 |
3 | type PublicInfo struct {
4 | Version string
5 | OperatingSystem string
6 | }
7 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/report.go:
--------------------------------------------------------------------------------
1 | package structs
2 |
3 | import "time"
4 |
5 | type Seasons map[int][]Intro
6 |
7 | type Report struct {
8 | Path string `json:"-"`
9 |
10 | StartedAt time.Time
11 | FinishedAt time.Time
12 | Runtime time.Duration
13 |
14 | ServerInfo PublicInfo
15 | PluginConfig PluginConfiguration
16 |
17 | Intros []Intro
18 |
19 | // Intro lookup table. Only populated when loading a report.
20 | IntroMap map[string]Intro `json:"-"`
21 |
22 | // Intros which have been sorted by show and season number. Only populated when loading a report.
23 | Shows map[string]Seasons `json:"-"`
24 | }
25 |
26 | // Data passed to the report template.
27 | type TemplateReportData struct {
28 | // First report.
29 | OldReport Report
30 |
31 | // Second report.
32 | NewReport Report
33 | }
34 |
35 | // A pair of introductions from an old and new reports.
36 | type IntroPair struct {
37 | Old Intro
38 | New Intro
39 |
40 | // Recognized warning types:
41 | // * okay: no warning
42 | // * different: timestamps are too dissimilar
43 | // * only_previous: introduction found in old report but not new one
44 | WarningShort string
45 |
46 | // If this pair of intros is not okay, a short description about the cause
47 | Warning string
48 | }
49 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/exec.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "fmt"
7 | "io"
8 | "os/exec"
9 | "regexp"
10 | "strings"
11 | "time"
12 | )
13 |
14 | // Run an external program
15 | func RunProgram(program string, args []string, timeout time.Duration) {
16 | // Flag if we are starting or stopping a container
17 | managingContainer := program == "docker"
18 |
19 | // Create context and command
20 | ctx, cancel := context.WithTimeout(context.Background(), timeout)
21 | defer cancel()
22 | cmd := exec.CommandContext(ctx, program, args...)
23 |
24 | // Stringify and censor the program's arguments
25 | strArgs := redactString(strings.Join(args, " "))
26 | fmt.Printf(" [+] Running %s %s\n", program, strArgs)
27 |
28 | // Setup pipes
29 | stdout, err := cmd.StdoutPipe()
30 | if err != nil {
31 | panic(err)
32 | }
33 |
34 | stderr, err := cmd.StderrPipe()
35 | if err != nil {
36 | panic(err)
37 | }
38 |
39 | // Start the command
40 | if err := cmd.Start(); err != nil {
41 | panic(err)
42 | }
43 |
44 | // Stream any messages to the terminal
45 | for _, r := range []io.Reader{stdout, stderr} {
46 | // Don't log stdout from the container
47 | if managingContainer && r == stdout {
48 | continue
49 | }
50 |
51 | scanner := bufio.NewScanner(r)
52 | scanner.Split(bufio.ScanRunes)
53 |
54 | for scanner.Scan() {
55 | fmt.Print(scanner.Text())
56 | }
57 | }
58 | }
59 |
60 | // Redacts sensitive command line arguments.
61 | func redactString(raw string) string {
62 | redactionRegex := regexp.MustCompilePOSIX(`-(user|pass|key) [^ ]+`)
63 | return redactionRegex.ReplaceAllString(raw, "-$1 REDACTED")
64 | }
65 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/exec_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "testing"
4 |
5 | func TestStringRedaction(t *testing.T) {
6 | raw := "-key deadbeef -first second -user admin -third fourth -pass hunter2"
7 | expected := "-key REDACTED -first second -user REDACTED -third fourth -pass REDACTED"
8 | actual := redactString(raw)
9 |
10 | if expected != actual {
11 | t.Errorf(`String was redacted incorrectly: "%s"`, actual)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/confusedpolarbear/intro_skipper_wrapper
2 |
3 | go 1.17
4 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/library.json:
--------------------------------------------------------------------------------
1 | {
2 | "LibraryOptions": {
3 | "EnableArchiveMediaFiles": false,
4 | "EnablePhotos": false,
5 | "EnableRealtimeMonitor": false,
6 | "ExtractChapterImagesDuringLibraryScan": false,
7 | "EnableChapterImageExtraction": false,
8 | "EnableInternetProviders": false,
9 | "SaveLocalMetadata": false,
10 | "EnableAutomaticSeriesGrouping": false,
11 | "PreferredMetadataLanguage": "",
12 | "MetadataCountryCode": "",
13 | "SeasonZeroDisplayName": "Specials",
14 | "AutomaticRefreshIntervalDays": 0,
15 | "EnableEmbeddedTitles": false,
16 | "EnableEmbeddedEpisodeInfos": false,
17 | "AllowEmbeddedSubtitles": "AllowAll",
18 | "SkipSubtitlesIfEmbeddedSubtitlesPresent": false,
19 | "SkipSubtitlesIfAudioTrackMatches": false,
20 | "SaveSubtitlesWithMedia": true,
21 | "RequirePerfectSubtitleMatch": true,
22 | "AutomaticallyAddToCollection": false,
23 | "MetadataSavers": [],
24 | "TypeOptions": [
25 | {
26 | "Type": "Series",
27 | "MetadataFetchers": [
28 | "TheMovieDb",
29 | "The Open Movie Database"
30 | ],
31 | "MetadataFetcherOrder": [
32 | "TheMovieDb",
33 | "The Open Movie Database"
34 | ],
35 | "ImageFetchers": [
36 | "TheMovieDb"
37 | ],
38 | "ImageFetcherOrder": [
39 | "TheMovieDb"
40 | ]
41 | },
42 | {
43 | "Type": "Season",
44 | "MetadataFetchers": [
45 | "TheMovieDb"
46 | ],
47 | "MetadataFetcherOrder": [
48 | "TheMovieDb"
49 | ],
50 | "ImageFetchers": [
51 | "TheMovieDb"
52 | ],
53 | "ImageFetcherOrder": [
54 | "TheMovieDb"
55 | ]
56 | },
57 | {
58 | "Type": "Episode",
59 | "MetadataFetchers": [
60 | "TheMovieDb",
61 | "The Open Movie Database"
62 | ],
63 | "MetadataFetcherOrder": [
64 | "TheMovieDb",
65 | "The Open Movie Database"
66 | ],
67 | "ImageFetchers": [
68 | "TheMovieDb",
69 | "The Open Movie Database",
70 | "Embedded Image Extractor",
71 | "Screen Grabber"
72 | ],
73 | "ImageFetcherOrder": [
74 | "TheMovieDb",
75 | "The Open Movie Database",
76 | "Embedded Image Extractor",
77 | "Screen Grabber"
78 | ]
79 | }
80 | ],
81 | "LocalMetadataReaderOrder": [
82 | "Nfo"
83 | ],
84 | "SubtitleDownloadLanguages": [],
85 | "DisabledSubtitleFetchers": [],
86 | "SubtitleFetcherOrder": [],
87 | "PathInfos": [
88 | {
89 | "Path": "/media/TV"
90 | }
91 | ]
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/setup.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | _ "embed"
6 | "fmt"
7 | "net/http"
8 | )
9 |
10 | //go:embed library.json
11 | var librarySetupPayload string
12 |
13 | func SetupServer(server, password string) {
14 | makeUrl := func(u string) string {
15 | return fmt.Sprintf("%s/%s", server, u)
16 | }
17 |
18 | // Set the server language to English
19 | sendRequest(
20 | makeUrl("Startup/Configuration"),
21 | "POST",
22 | `{"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}`)
23 |
24 | // Get the first user
25 | sendRequest(makeUrl("Startup/User"), "GET", "")
26 |
27 | // Create the first user
28 | sendRequest(
29 | makeUrl("Startup/User"),
30 | "POST",
31 | fmt.Sprintf(`{"Name":"admin","Password":"%s"}`, password))
32 |
33 | // Create a TV library from the media at /media/TV.
34 | sendRequest(
35 | makeUrl("Library/VirtualFolders?collectionType=tvshows&refreshLibrary=false&name=Shows"),
36 | "POST",
37 | librarySetupPayload)
38 |
39 | // Setup remote access
40 | sendRequest(
41 | makeUrl("Startup/RemoteAccess"),
42 | "POST",
43 | `{"EnableRemoteAccess":true,"EnableAutomaticPortMapping":false}`)
44 |
45 | // Mark the wizard as complete
46 | sendRequest(
47 | makeUrl("Startup/Complete"),
48 | "POST",
49 | ``)
50 | }
51 |
52 | func sendRequest(url string, method string, body string) {
53 | // Create the request
54 | req, err := http.NewRequest(method, url, bytes.NewBuffer([]byte(body)))
55 | if err != nil {
56 | panic(err)
57 | }
58 |
59 | // Set required headers
60 | req.Header.Set("Content-Type", "application/json")
61 | req.Header.Set(
62 | "X-Emby-Authorization",
63 | `MediaBrowser Client="JF E2E Tests", Version="0.0.1", DeviceId="E2E", Device="E2E"`)
64 |
65 | // Send it
66 | fmt.Printf(" [+] %s %s", method, url)
67 | res, err := http.DefaultClient.Do(req)
68 |
69 | if err != nil {
70 | fmt.Println()
71 | panic(err)
72 | }
73 |
74 | fmt.Printf(" %d\n", res.StatusCode)
75 |
76 | if res.StatusCode != http.StatusNoContent && res.StatusCode != http.StatusOK {
77 | panic("invalid status code received during setup")
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/structs.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | type Configuration struct {
4 | Common Common `json:"common"`
5 | Servers []Server `json:"servers"`
6 | }
7 |
8 | type Common struct {
9 | Library string `json:"library"`
10 | Episode string `json:"episode"`
11 | }
12 |
13 | type Server struct {
14 | Skip bool `json:"skip"`
15 | Comment string `json:"comment"`
16 | Address string `json:"address"`
17 | Image string `json:"image"`
18 | Username string `json:"username"`
19 | Password string `json:"password"`
20 | Browsers []string `json:"browsers"`
21 | Tests []string `json:"tests"`
22 | ManualTests bool `json:"manual_tests"`
23 |
24 | // These properties are set at runtime
25 | Docker bool `json:"-"`
26 | }
27 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/686c978a2f5f57a909dc6e76029a0c225e2e3ec4/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/686c978a2f5f57a909dc6e76029a0c225e2e3ec4/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper.sln:
--------------------------------------------------------------------------------
1 | Microsoft Visual Studio Solution File, Format Version 12.00
2 | #
3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper", "ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
4 | EndProject
5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper.Tests", "ConfusedPolarBear.Plugin.IntroSkipper.Tests\ConfusedPolarBear.Plugin.IntroSkipper.Tests.csproj", "{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}"
6 | EndProject
7 | Global
8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
9 | Debug|Any CPU = Debug|Any CPU
10 | Release|Any CPU = Release|Any CPU
11 | EndGlobalSection
12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
13 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
14 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU
15 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.ActiveCfg = Release|Any CPU
16 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.Build.0 = Release|Any CPU
17 | {9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
18 | {9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Debug|Any CPU.Build.0 = Debug|Any CPU
19 | {9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Release|Any CPU.ActiveCfg = Release|Any CPU
20 | {9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Release|Any CPU.Build.0 = Release|Any CPU
21 | EndGlobalSection
22 | EndGlobal
23 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
2 |
3 | using System;
4 | using System.Collections.Generic;
5 | using System.Collections.ObjectModel;
6 | using System.Linq;
7 | using System.Threading;
8 | using Microsoft.Extensions.Logging;
9 |
10 | ///
11 | /// Media file analyzer used to detect end credits that consist of text overlaid on a black background.
12 | /// Bisects the end of the video file to perform an efficient search.
13 | ///
14 | public class BlackFrameAnalyzer : IMediaFileAnalyzer
15 | {
16 | private readonly TimeSpan _maximumError = new(0, 0, 4);
17 |
18 | private readonly ILogger _logger;
19 |
20 | ///
21 | /// Initializes a new instance of the class.
22 | ///
23 | /// Logger.
24 | public BlackFrameAnalyzer(ILogger logger)
25 | {
26 | _logger = logger;
27 | }
28 |
29 | ///
30 | public ReadOnlyCollection AnalyzeMediaFiles(
31 | ReadOnlyCollection analysisQueue,
32 | AnalysisMode mode,
33 | CancellationToken cancellationToken)
34 | {
35 | if (mode != AnalysisMode.Credits)
36 | {
37 | throw new NotImplementedException("mode must equal Credits");
38 | }
39 |
40 | var creditTimes = new Dictionary();
41 |
42 | foreach (var episode in analysisQueue)
43 | {
44 | if (cancellationToken.IsCancellationRequested)
45 | {
46 | break;
47 | }
48 |
49 | var intro = AnalyzeMediaFile(
50 | episode,
51 | mode,
52 | Plugin.Instance!.Configuration.BlackFrameMinimumPercentage);
53 |
54 | if (intro is null)
55 | {
56 | continue;
57 | }
58 |
59 | creditTimes[episode.EpisodeId] = intro;
60 | }
61 |
62 | Plugin.Instance!.UpdateTimestamps(creditTimes, mode);
63 |
64 | return analysisQueue
65 | .Where(x => !creditTimes.ContainsKey(x.EpisodeId))
66 | .ToList()
67 | .AsReadOnly();
68 | }
69 |
70 | ///
71 | /// Analyzes an individual media file. Only public because of unit tests.
72 | ///
73 | /// Media file to analyze.
74 | /// Analysis mode.
75 | /// Percentage of the frame that must be black.
76 | /// Credits timestamp.
77 | public Intro? AnalyzeMediaFile(QueuedEpisode episode, AnalysisMode mode, int minimum)
78 | {
79 | var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
80 |
81 | // Start by analyzing the last N minutes of the file.
82 | var start = TimeSpan.FromSeconds(config.MaximumEpisodeCreditsDuration);
83 | var end = TimeSpan.FromSeconds(config.MinimumCreditsDuration);
84 | var firstFrameTime = 0.0;
85 |
86 | // Continue bisecting the end of the file until the range that contains the first black
87 | // frame is smaller than the maximum permitted error.
88 | while (start - end > _maximumError)
89 | {
90 | // Analyze the middle two seconds from the current bisected range
91 | var midpoint = (start + end) / 2;
92 | var scanTime = episode.Duration - midpoint.TotalSeconds;
93 | var tr = new TimeRange(scanTime, scanTime + 2);
94 |
95 | _logger.LogTrace(
96 | "{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]",
97 | episode.Name,
98 | episode.Duration,
99 | start,
100 | end,
101 | tr.Start,
102 | tr.End);
103 |
104 | var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum);
105 | _logger.LogTrace(
106 | "{Episode} at {Start} has {Count} black frames",
107 | episode.Name,
108 | tr.Start,
109 | frames.Length);
110 |
111 | if (frames.Length == 0)
112 | {
113 | // Since no black frames were found, slide the range closer to the end
114 | start = midpoint;
115 | }
116 | else
117 | {
118 | // Some black frames were found, slide the range closer to the start
119 | end = midpoint;
120 | firstFrameTime = frames[0].Time + scanTime;
121 | }
122 | }
123 |
124 | if (firstFrameTime > 0)
125 | {
126 | return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration));
127 | }
128 |
129 | return null;
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
2 |
3 | using System;
4 | using System.Collections.Generic;
5 | using System.Collections.ObjectModel;
6 | using System.Globalization;
7 | using System.Linq;
8 | using System.Text.RegularExpressions;
9 | using System.Threading;
10 | using Microsoft.Extensions.Logging;
11 | using MediaBrowser.Model.Entities;
12 |
13 | ///
14 | /// Chapter name analyzer.
15 | ///
16 | public class ChapterAnalyzer : IMediaFileAnalyzer
17 | {
18 | private ILogger _logger;
19 |
20 | ///
21 | /// Initializes a new instance of the class.
22 | ///
23 | /// Logger.
24 | public ChapterAnalyzer(ILogger logger)
25 | {
26 | _logger = logger;
27 | }
28 |
29 | ///
30 | public ReadOnlyCollection AnalyzeMediaFiles(
31 | ReadOnlyCollection analysisQueue,
32 | AnalysisMode mode,
33 | CancellationToken cancellationToken)
34 | {
35 | var skippableRanges = new Dictionary();
36 |
37 | var expression = mode == AnalysisMode.Introduction ?
38 | Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern :
39 | Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern;
40 |
41 | if (string.IsNullOrWhiteSpace(expression))
42 | {
43 | return analysisQueue;
44 | }
45 |
46 | foreach (var episode in analysisQueue)
47 | {
48 | if (cancellationToken.IsCancellationRequested)
49 | {
50 | break;
51 | }
52 |
53 | var skipRange = FindMatchingChapter(
54 | episode,
55 | new(Plugin.Instance!.GetChapters(episode.EpisodeId)),
56 | expression,
57 | mode);
58 |
59 | if (skipRange is null)
60 | {
61 | continue;
62 | }
63 |
64 | skippableRanges.Add(episode.EpisodeId, skipRange);
65 | }
66 |
67 | Plugin.Instance!.UpdateTimestamps(skippableRanges, mode);
68 |
69 | return analysisQueue
70 | .Where(x => !skippableRanges.ContainsKey(x.EpisodeId))
71 | .ToList()
72 | .AsReadOnly();
73 | }
74 |
75 | ///
76 | /// Searches a list of chapter names for one that matches the provided regular expression.
77 | /// Only public to allow for unit testing.
78 | ///
79 | /// Episode.
80 | /// Media item chapters.
81 | /// Regular expression pattern.
82 | /// Analysis mode.
83 | /// Intro object containing skippable time range, or null if no chapter matched.
84 | public Intro? FindMatchingChapter(
85 | QueuedEpisode episode,
86 | Collection chapters,
87 | string expression,
88 | AnalysisMode mode)
89 | {
90 | Intro? matchingChapter = null;
91 |
92 | var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
93 |
94 | var minDuration = config.MinimumIntroDuration;
95 | int maxDuration = mode == AnalysisMode.Introduction ?
96 | config.MaximumIntroDuration :
97 | config.MaximumEpisodeCreditsDuration;
98 |
99 | if (mode == AnalysisMode.Credits)
100 | {
101 | // Since the ending credits chapter may be the last chapter in the file, append a virtual
102 | // chapter at the very end of the file.
103 | chapters.Add(new()
104 | {
105 | StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks
106 | });
107 | }
108 |
109 | // Check all chapters
110 | for (int i = 0; i < chapters.Count - 1; i++)
111 | {
112 | var current = chapters[i];
113 | var next = chapters[i + 1];
114 |
115 | if (string.IsNullOrWhiteSpace(current.Name))
116 | {
117 | continue;
118 | }
119 |
120 | var currentRange = new TimeRange(
121 | TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
122 | TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
123 |
124 | var baseMessage = string.Format(
125 | CultureInfo.InvariantCulture,
126 | "{0}: Chapter \"{1}\" ({2} - {3})",
127 | episode.Path,
128 | current.Name,
129 | currentRange.Start,
130 | currentRange.End);
131 |
132 | if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration)
133 | {
134 | _logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage);
135 | continue;
136 | }
137 |
138 | // Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex
139 | // between function invocations.
140 | var match = Regex.IsMatch(
141 | current.Name,
142 | expression,
143 | RegexOptions.None,
144 | TimeSpan.FromSeconds(1));
145 |
146 | if (!match)
147 | {
148 | _logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
149 | continue;
150 | }
151 |
152 | matchingChapter = new(episode.EpisodeId, currentRange);
153 | _logger.LogTrace("{Base}: okay", baseMessage);
154 | break;
155 | }
156 |
157 | return matchingChapter;
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/IMediaFileAnalyzer.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
2 |
3 | using System.Collections.ObjectModel;
4 | using System.Threading;
5 |
6 | ///
7 | /// Media file analyzer interface.
8 | ///
9 | public interface IMediaFileAnalyzer
10 | {
11 | ///
12 | /// Analyze media files for shared introductions or credits, returning all media files that were **not successfully analyzed**.
13 | ///
14 | /// Collection of unanalyzed media files.
15 | /// Analysis mode.
16 | /// Cancellation token from scheduled task.
17 | /// Collection of media files that were **unsuccessfully analyzed**.
18 | public ReadOnlyCollection AnalyzeMediaFiles(
19 | ReadOnlyCollection analysisQueue,
20 | AnalysisMode mode,
21 | CancellationToken cancellationToken);
22 | }
23 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Threading;
4 | using System.Threading.Tasks;
5 | using System.Timers;
6 | using MediaBrowser.Common.Extensions;
7 | using MediaBrowser.Controller.Library;
8 | using MediaBrowser.Controller.Plugins;
9 | using MediaBrowser.Controller.Session;
10 | using MediaBrowser.Model.Entities;
11 | using MediaBrowser.Model.Session;
12 | using Microsoft.Extensions.Logging;
13 |
14 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
15 |
16 | ///
17 | /// Automatically skip past introduction sequences.
18 | /// Commands clients to seek to the end of the intro as soon as they start playing it.
19 | ///
20 | public class AutoSkip : IServerEntryPoint
21 | {
22 | private readonly object _sentSeekCommandLock = new();
23 |
24 | private ILogger _logger;
25 | private IUserDataManager _userDataManager;
26 | private ISessionManager _sessionManager;
27 | private System.Timers.Timer _playbackTimer = new(1000);
28 | private Dictionary _sentSeekCommand;
29 |
30 | ///
31 | /// Initializes a new instance of the class.
32 | ///
33 | /// User data manager.
34 | /// Session manager.
35 | /// Logger.
36 | public AutoSkip(
37 | IUserDataManager userDataManager,
38 | ISessionManager sessionManager,
39 | ILogger logger)
40 | {
41 | _userDataManager = userDataManager;
42 | _sessionManager = sessionManager;
43 | _logger = logger;
44 | _sentSeekCommand = new Dictionary();
45 | }
46 |
47 | ///
48 | /// If introduction auto skipping is enabled, set it up.
49 | ///
50 | /// Task.
51 | public Task RunAsync()
52 | {
53 | _logger.LogDebug("Setting up automatic skipping");
54 |
55 | _userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
56 | Plugin.Instance!.AutoSkipChanged += AutoSkipChanged;
57 |
58 | // Make the timer restart automatically and set enabled to match the configuration value.
59 | _playbackTimer.AutoReset = true;
60 | _playbackTimer.Elapsed += PlaybackTimer_Elapsed;
61 |
62 | AutoSkipChanged(null, EventArgs.Empty);
63 |
64 | return Task.CompletedTask;
65 | }
66 |
67 | private void AutoSkipChanged(object? sender, EventArgs e)
68 | {
69 | var newState = Plugin.Instance!.Configuration.AutoSkip;
70 | _logger.LogDebug("Setting playback timer enabled to {NewState}", newState);
71 | _playbackTimer.Enabled = newState;
72 | }
73 |
74 | private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
75 | {
76 | var itemId = e.Item.Id;
77 | var newState = false;
78 | var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
79 |
80 | // Ignore all events except playback start & end
81 | if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
82 | {
83 | return;
84 | }
85 |
86 | // Lookup the session for this item.
87 | SessionInfo? session = null;
88 |
89 | try
90 | {
91 | foreach (var needle in _sessionManager.Sessions)
92 | {
93 | if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
94 | {
95 | session = needle;
96 | break;
97 | }
98 | }
99 |
100 | if (session == null)
101 | {
102 | _logger.LogInformation("Unable to find session for {Item}", itemId);
103 | return;
104 | }
105 | }
106 | catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
107 | {
108 | return;
109 | }
110 |
111 | // If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session.
112 | if (!Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
113 | {
114 | newState = true;
115 | }
116 |
117 | // Reset the seek command state for this device.
118 | lock (_sentSeekCommandLock)
119 | {
120 | var device = session.DeviceId;
121 |
122 | _logger.LogDebug("Resetting seek command state for session {Session}", device);
123 | _sentSeekCommand[device] = newState;
124 | }
125 | }
126 |
127 | private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
128 | {
129 | foreach (var session in _sessionManager.Sessions)
130 | {
131 | var deviceId = session.DeviceId;
132 | var itemId = session.NowPlayingItem.Id;
133 | var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
134 |
135 | // Don't send the seek command more than once in the same session.
136 | lock (_sentSeekCommandLock)
137 | {
138 | if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent)
139 | {
140 | _logger.LogTrace("Already sent seek command for session {Session}", deviceId);
141 | continue;
142 | }
143 | }
144 |
145 | // Assert that an intro was detected for this item.
146 | if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid)
147 | {
148 | continue;
149 | }
150 |
151 | // Seek is unreliable if called at the very start of an episode.
152 | var adjustedStart = Math.Max(5, intro.IntroStart);
153 |
154 | _logger.LogTrace(
155 | "Playback position is {Position}, intro runs from {Start} to {End}",
156 | position,
157 | adjustedStart,
158 | intro.IntroEnd);
159 |
160 | if (position < adjustedStart || position > intro.IntroEnd)
161 | {
162 | continue;
163 | }
164 |
165 | // Notify the user that an introduction is being skipped for them.
166 | var notificationText = Plugin.Instance!.Configuration.AutoSkipNotificationText;
167 | if (!string.IsNullOrWhiteSpace(notificationText))
168 | {
169 | _sessionManager.SendMessageCommand(
170 | session.Id,
171 | session.Id,
172 | new MessageCommand()
173 | {
174 | Header = string.Empty, // some clients require header to be a string instead of null
175 | Text = notificationText,
176 | TimeoutMs = 2000,
177 | },
178 | CancellationToken.None);
179 | }
180 |
181 | _logger.LogDebug("Sending seek command to {Session}", deviceId);
182 |
183 | var introEnd = (long)intro.IntroEnd - Plugin.Instance!.Configuration.SecondsOfIntroToPlay;
184 |
185 | _sessionManager.SendPlaystateCommand(
186 | session.Id,
187 | session.Id,
188 | new PlaystateRequest
189 | {
190 | Command = PlaystateCommand.Seek,
191 | ControllingUserId = session.UserId.ToString("N"),
192 | SeekPositionTicks = introEnd * TimeSpan.TicksPerSecond,
193 | },
194 | CancellationToken.None);
195 |
196 | // Flag that we've sent the seek command so that it's not sent repeatedly
197 | lock (_sentSeekCommandLock)
198 | {
199 | _logger.LogTrace("Setting seek command state for session {Session}", deviceId);
200 | _sentSeekCommand[deviceId] = true;
201 | }
202 | }
203 | }
204 |
205 | ///
206 | /// Dispose.
207 | ///
208 | public void Dispose()
209 | {
210 | Dispose(true);
211 | GC.SuppressFinalize(this);
212 | }
213 |
214 | ///
215 | /// Protected dispose.
216 | ///
217 | /// Dispose.
218 | protected virtual void Dispose(bool disposing)
219 | {
220 | if (!disposing)
221 | {
222 | return;
223 | }
224 |
225 | _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
226 | _playbackTimer.Stop();
227 | _playbackTimer.Dispose();
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs:
--------------------------------------------------------------------------------
1 | using MediaBrowser.Model.Plugins;
2 |
3 | namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
4 |
5 | ///
6 | /// Plugin configuration.
7 | ///
8 | public class PluginConfiguration : BasePluginConfiguration
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | public PluginConfiguration()
14 | {
15 | }
16 |
17 | // ===== Analysis settings =====
18 |
19 | ///
20 | /// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem.
21 | ///
22 | public bool CacheFingerprints { get; set; } = true;
23 |
24 | ///
25 | /// Gets or sets the max degree of parallelism used when analyzing episodes.
26 | ///
27 | public int MaxParallelism { get; set; } = 2;
28 |
29 | ///
30 | /// Gets or sets the comma separated list of library names to analyze. If empty, all libraries will be analyzed.
31 | ///
32 | public string SelectedLibraries { get; set; } = string.Empty;
33 |
34 | ///
35 | /// Gets or sets a value indicating whether to analyze season 0.
36 | ///
37 | public bool AnalyzeSeasonZero { get; set; } = false;
38 |
39 | // ===== EDL handling =====
40 |
41 | ///
42 | /// Gets or sets a value indicating the action to write to created EDL files.
43 | ///
44 | public EdlAction EdlAction { get; set; } = EdlAction.None;
45 |
46 | ///
47 | /// Gets or sets a value indicating whether to regenerate all EDL files during the next scan.
48 | /// By default, EDL files are only written for a season if the season had at least one newly analyzed episode.
49 | /// If this is set, all EDL files will be regenerated and overwrite any existing EDL file.
50 | ///
51 | public bool RegenerateEdlFiles { get; set; } = false;
52 |
53 | // ===== Custom analysis settings =====
54 |
55 | ///
56 | /// Gets or sets the percentage of each episode's audio track to analyze.
57 | ///
58 | public int AnalysisPercent { get; set; } = 25;
59 |
60 | ///
61 | /// Gets or sets the upper limit (in minutes) on the length of each episode's audio track that will be analyzed.
62 | ///
63 | public int AnalysisLengthLimit { get; set; } = 10;
64 |
65 | ///
66 | /// Gets or sets the minimum length of similar audio that will be considered an introduction.
67 | ///
68 | public int MinimumIntroDuration { get; set; } = 15;
69 |
70 | ///
71 | /// Gets or sets the maximum length of similar audio that will be considered an introduction.
72 | ///
73 | public int MaximumIntroDuration { get; set; } = 120;
74 |
75 | ///
76 | /// Gets or sets the minimum length of similar audio that will be considered ending credits.
77 | ///
78 | public int MinimumCreditsDuration { get; set; } = 15;
79 |
80 | ///
81 | /// 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.
82 | ///
83 | public int MaximumEpisodeCreditsDuration { get; set; } = 240;
84 |
85 | ///
86 | /// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
87 | ///
88 | public int BlackFrameMinimumPercentage { get; set; } = 85;
89 |
90 | ///
91 | /// Gets or sets the regular expression used to detect introduction chapters.
92 | ///
93 | public string ChapterAnalyzerIntroductionPattern { get; set; } =
94 | @"(^|\s)(Intro|Introduction|OP|Opening)(\s|$)";
95 |
96 | ///
97 | /// Gets or sets the regular expression used to detect ending credit chapters.
98 | ///
99 | public string ChapterAnalyzerEndCreditsPattern { get; set; } =
100 | @"(^|\s)(Credits?|Ending)(\s|$)";
101 |
102 | // ===== Playback settings =====
103 |
104 | ///
105 | /// Gets or sets a value indicating whether to show the skip intro button.
106 | ///
107 | public bool SkipButtonVisible { get; set; } = true;
108 |
109 | ///
110 | /// Gets or sets a value indicating whether introductions should be automatically skipped.
111 | ///
112 | public bool AutoSkip { get; set; }
113 |
114 | ///
115 | /// Gets or sets the seconds before the intro starts to show the skip prompt at.
116 | ///
117 | public int ShowPromptAdjustment { get; set; } = 5;
118 |
119 | ///
120 | /// Gets or sets the seconds after the intro starts to hide the skip prompt at.
121 | ///
122 | public int HidePromptAdjustment { get; set; } = 10;
123 |
124 | ///
125 | /// Gets or sets a value indicating whether the introduction in the first episode of a season should be skipped.
126 | ///
127 | public bool SkipFirstEpisode { get; set; } = true;
128 |
129 | ///
130 | /// Gets or sets the amount of intro to play (in seconds).
131 | ///
132 | public int SecondsOfIntroToPlay { get; set; } = 2;
133 |
134 | // ===== Internal algorithm settings =====
135 |
136 | ///
137 | /// 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.
138 | /// Defaults to 6 (81% similar).
139 | ///
140 | public int MaximumFingerprintPointDifferences { get; set; } = 6;
141 |
142 | ///
143 | /// Gets or sets the maximum number of seconds that can pass between two similar fingerprint points before a new time range is started.
144 | ///
145 | public double MaximumTimeSkip { get; set; } = 3.5;
146 |
147 | ///
148 | /// Gets or sets the amount to shift inverted indexes by.
149 | ///
150 | public int InvertedIndexShift { get; set; } = 2;
151 |
152 | ///
153 | /// Gets or sets the maximum amount of noise (in dB) that is considered silent.
154 | /// Lowering this number will increase the filter's sensitivity to noise.
155 | ///
156 | public int SilenceDetectionMaximumNoise { get; set; } = -50;
157 |
158 | ///
159 | /// Gets or sets the minimum duration of audio (in seconds) that is considered silent.
160 | ///
161 | public double SilenceDetectionMinimumDuration { get; set; } = 0.33;
162 |
163 | // ===== Localization support =====
164 |
165 | ///
166 | /// Gets or sets the text to display in the skip button in introduction mode.
167 | ///
168 | public string SkipButtonIntroText { get; set; } = "Skip Intro";
169 |
170 | ///
171 | /// Gets or sets the text to display in the skip button in end credits mode.
172 | ///
173 | public string SkipButtonEndCreditsText { get; set; } = "Next";
174 |
175 | ///
176 | /// Gets or sets the notification text sent after automatically skipping an introduction.
177 | ///
178 | public string AutoSkipNotificationText { get; set; } = "Automatically skipped intro";
179 | }
180 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/UserInterfaceConfiguration.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
2 |
3 | ///
4 | /// User interface configuration.
5 | ///
6 | public class UserInterfaceConfiguration
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | /// Skip button visibility.
12 | /// Skip button intro text.
13 | /// Skip button end credits text.
14 | public UserInterfaceConfiguration(bool visible, string introText, string creditsText)
15 | {
16 | SkipButtonVisible = visible;
17 | SkipButtonIntroText = introText;
18 | SkipButtonEndCreditsText = creditsText;
19 | }
20 |
21 | ///
22 | /// Gets or sets a value indicating whether to show the skip intro button.
23 | ///
24 | public bool SkipButtonVisible { get; set; }
25 |
26 | ///
27 | /// Gets or sets the text to display in the skip intro button in introduction mode.
28 | ///
29 | public string SkipButtonIntroText { get; set; }
30 |
31 | ///
32 | /// Gets or sets the text to display in the skip intro button in end credits mode.
33 | ///
34 | public string SkipButtonEndCreditsText { get; set; }
35 | }
36 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt:
--------------------------------------------------------------------------------
1 | unknown
2 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js:
--------------------------------------------------------------------------------
1 | // re-render the troubleshooter with the latest offset
2 | function renderTroubleshooter() {
3 | paintFingerprintDiff(canvas, lhs, rhs, Number(offset.value));
4 | findIntros();
5 | }
6 |
7 | // refresh the upper & lower bounds for the offset
8 | function refreshBounds() {
9 | const len = Math.min(lhs.length, rhs.length) - 1;
10 | offset.min = -1 * len;
11 | offset.max = len;
12 | }
13 |
14 | function findIntros() {
15 | let times = [];
16 |
17 | // get the times of all similar fingerprint points
18 | for (let i in fprDiffs) {
19 | if (fprDiffs[i] > fprDiffMinimum) {
20 | times.push(i * 0.128);
21 | }
22 | }
23 |
24 | // always close the last range
25 | times.push(Number.MAX_VALUE);
26 |
27 | let last = times[0];
28 | let start = last;
29 | let end = last;
30 | let ranges = [];
31 |
32 | for (let t of times) {
33 | const diff = t - last;
34 |
35 | if (diff <= 3.5) {
36 | end = t;
37 | last = t;
38 | continue;
39 | }
40 |
41 | const dur = Math.round(end - start);
42 | if (dur >= 15) {
43 | ranges.push({
44 | "start": start,
45 | "end": end,
46 | "duration": dur
47 | });
48 | }
49 |
50 | start = t;
51 | end = t;
52 | last = t;
53 | }
54 |
55 | const introsLog = document.querySelector("span#intros");
56 | introsLog.style.position = "relative";
57 | introsLog.style.left = "115px";
58 | introsLog.innerHTML = "";
59 |
60 | const offset = Number(txtOffset.value) * 0.128;
61 | for (let r of ranges) {
62 | let lStart, lEnd, rStart, rEnd;
63 |
64 | if (offset < 0) {
65 | // negative offset, the diff is aligned with the RHS
66 | lStart = r.start - offset;
67 | lEnd = r.end - offset;
68 | rStart = r.start;
69 | rEnd = r.end;
70 |
71 | } else {
72 | // positive offset, the diff is aligned with the LHS
73 | lStart = r.start;
74 | lEnd = r.end;
75 | rStart = r.start + offset;
76 | rEnd = r.end + offset;
77 | }
78 |
79 | const lTitle = selectEpisode1.options[selectEpisode1.selectedIndex].text;
80 | const rTitle = selectEpisode2.options[selectEpisode2.selectedIndex].text;
81 | introsLog.innerHTML += "" + lTitle + ": " +
82 | secondsToString(lStart) + " - " + secondsToString(lEnd) + "
";
83 | introsLog.innerHTML += "" + rTitle + ": " +
84 | secondsToString(rStart) + " - " + secondsToString(rEnd) + "
";
85 | }
86 | }
87 |
88 | // find all shifts which align exact matches of audio.
89 | function findExactMatches() {
90 | let shifts = [];
91 |
92 | for (let lhsIndex in lhs) {
93 | let lhsPoint = lhs[lhsIndex];
94 | let rhsIndex = rhs.findIndex((x) => x === lhsPoint);
95 |
96 | if (rhsIndex === -1) {
97 | continue;
98 | }
99 |
100 | let shift = rhsIndex - lhsIndex;
101 | if (shifts.includes(shift)) {
102 | continue;
103 | }
104 |
105 | shifts.push(shift);
106 | }
107 |
108 | // Only suggest up to 20 shifts
109 | shifts = shifts.slice(0, 20);
110 |
111 | txtSuggested.textContent = "Suggested shifts: ";
112 | if (shifts.length === 0) {
113 | txtSuggested.textContent += "none available";
114 | } else {
115 | shifts.sort((a, b) => { return a - b });
116 | txtSuggested.textContent += shifts.join(", ");
117 | }
118 | }
119 |
120 | // The below two functions were modified from https://github.com/dnknth/acoustid-match/blob/ffbf21d8c53c40d3b3b4c92238c35846545d3cd7/fingerprints/static/fingerprints/fputils.js
121 | // Originally licensed as MIT.
122 | function renderFingerprintData(ctx, fp, xor = false) {
123 | const pixels = ctx.createImageData(32, fp.length);
124 | let idx = 0;
125 |
126 | for (let i = 0; i < fp.length; i++) {
127 | for (let j = 0; j < 32; j++) {
128 | if (fp[i] & (1 << j)) {
129 | pixels.data[idx + 0] = 255;
130 | pixels.data[idx + 1] = 255;
131 | pixels.data[idx + 2] = 255;
132 |
133 | } else {
134 | pixels.data[idx + 0] = 0;
135 | pixels.data[idx + 1] = 0;
136 | pixels.data[idx + 2] = 0;
137 | }
138 |
139 | pixels.data[idx + 3] = 255;
140 | idx += 4;
141 | }
142 | }
143 |
144 | if (!xor) {
145 | return pixels;
146 | }
147 |
148 | // if rendering the XOR of the fingerprints, count how many bits are different at each timecode
149 | fprDiffs = [];
150 |
151 | for (let i = 0; i < fp.length; i++) {
152 | let count = 0;
153 |
154 | for (let j = 0; j < 32; j++) {
155 | if (fp[i] & (1 << j)) {
156 | count++;
157 | }
158 | }
159 |
160 | // push the percentage similarity
161 | fprDiffs[i] = 100 - (count * 100) / 32;
162 | }
163 |
164 | return pixels;
165 | }
166 |
167 | function paintFingerprintDiff(canvas, fp1, fp2, offset) {
168 | if (fp1.length == 0) {
169 | return;
170 | }
171 |
172 | let leftOffset = 0, rightOffset = 0;
173 | if (offset < 0) {
174 | leftOffset -= offset;
175 | } else {
176 | rightOffset += offset;
177 | }
178 |
179 | let fpDiff = [];
180 | fpDiff.length = Math.min(fp1.length, fp2.length) - Math.abs(offset);
181 | for (let i = 0; i < fpDiff.length; i++) {
182 | fpDiff[i] = fp1[i + leftOffset] ^ fp2[i + rightOffset];
183 | }
184 |
185 | const ctx = canvas.getContext('2d');
186 | const pixels1 = renderFingerprintData(ctx, fp1);
187 | const pixels2 = renderFingerprintData(ctx, fp2);
188 | const pixelsDiff = renderFingerprintData(ctx, fpDiff, true);
189 | const border = 4;
190 |
191 | canvas.width = pixels1.width + border + // left fingerprint
192 | pixels2.width + border + // right fingerprint
193 | pixelsDiff.width + border // fingerprint diff
194 | + 4; // if diff[x] >= fprDiffMinimum
195 |
196 | canvas.height = Math.max(pixels1.height, pixels2.height) + Math.abs(offset);
197 |
198 | ctx.rect(0, 0, canvas.width, canvas.height);
199 | ctx.fillStyle = "#C5C5C5";
200 | ctx.fill();
201 |
202 | // draw left fingerprint
203 | let dx = 0;
204 | ctx.putImageData(pixels1, dx, rightOffset);
205 | dx += pixels1.width + border;
206 |
207 | // draw right fingerprint
208 | ctx.putImageData(pixels2, dx, leftOffset);
209 | dx += pixels2.width + border;
210 |
211 | // draw fingerprint diff
212 | ctx.putImageData(pixelsDiff, dx, Math.abs(offset));
213 | dx += pixelsDiff.width + border;
214 |
215 | // draw the fingerprint diff similarity indicator
216 | // https://davidmathlogic.com/colorblind/#%23EA3535-%232C92EF
217 | for (let i in fprDiffs) {
218 | const j = Number(i);
219 | const y = Math.abs(offset) + j;
220 | const point = fprDiffs[j];
221 |
222 | if (point >= 100) {
223 | ctx.fillStyle = "#002FFF"
224 | } else if (point >= fprDiffMinimum) {
225 | ctx.fillStyle = "#2C92EF";
226 | } else {
227 | ctx.fillStyle = "#EA3535";
228 | }
229 |
230 | ctx.fillRect(dx, y, 4, 1);
231 | }
232 | }
233 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 | ConfusedPolarBear.Plugin.IntroSkipper
6 | 0.1.8.0
7 | 0.1.8.0
8 | true
9 | true
10 | enable
11 | AllEnabledByDefault
12 | ../jellyfin.ruleset
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Net.Mime;
4 | using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
5 | using MediaBrowser.Controller.Entities.TV;
6 | using Microsoft.AspNetCore.Authorization;
7 | using Microsoft.AspNetCore.Mvc;
8 |
9 | namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
10 |
11 | ///
12 | /// Skip intro controller.
13 | ///
14 | [Authorize]
15 | [ApiController]
16 | [Produces(MediaTypeNames.Application.Json)]
17 | public class SkipIntroController : ControllerBase
18 | {
19 | ///
20 | /// Initializes a new instance of the class.
21 | ///
22 | public SkipIntroController()
23 | {
24 | }
25 |
26 | ///
27 | /// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format.
28 | ///
29 | /// ID of the episode. Required.
30 | /// Timestamps to return. Optional. Defaults to Introduction for backwards compatibility.
31 | /// Episode contains an intro.
32 | /// Failed to find an intro in the provided episode.
33 | /// Detected intro.
34 | [HttpGet("Episode/{id}/IntroTimestamps")]
35 | [HttpGet("Episode/{id}/IntroTimestamps/v1")]
36 | public ActionResult GetIntroTimestamps(
37 | [FromRoute] Guid id,
38 | [FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
39 | {
40 | var intro = GetIntro(id, mode);
41 |
42 | if (intro is null || !intro.Valid)
43 | {
44 | return NotFound();
45 | }
46 |
47 | return intro;
48 | }
49 |
50 | ///
51 | /// Gets a dictionary of all skippable segments.
52 | ///
53 | /// Media ID.
54 | /// Skippable segments dictionary.
55 | /// Dictionary of skippable segments.
56 | [HttpGet("Episode/{id}/IntroSkipperSegments")]
57 | public ActionResult> GetSkippableSegments([FromRoute] Guid id)
58 | {
59 | var segments = new Dictionary();
60 |
61 | if (GetIntro(id, AnalysisMode.Introduction) is Intro intro)
62 | {
63 | segments[AnalysisMode.Introduction] = intro;
64 | }
65 |
66 | if (GetIntro(id, AnalysisMode.Credits) is Intro credits)
67 | {
68 | segments[AnalysisMode.Credits] = credits;
69 | }
70 |
71 | return segments;
72 | }
73 |
74 | /// Lookup and return the skippable timestamps for the provided item.
75 | /// Unique identifier of this episode.
76 | /// Mode.
77 | /// Intro object if the provided item has an intro, null otherwise.
78 | private Intro? GetIntro(Guid id, AnalysisMode mode)
79 | {
80 | try
81 | {
82 | var timestamp = mode == AnalysisMode.Introduction ?
83 | Plugin.Instance!.Intros[id] :
84 | Plugin.Instance!.Credits[id];
85 |
86 | // Operate on a copy to avoid mutating the original Intro object stored in the dictionary.
87 | var segment = new Intro(timestamp);
88 |
89 | var config = Plugin.Instance!.Configuration;
90 | segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
91 | segment.HideSkipPromptAt = Math.Min(
92 | segment.IntroStart + config.HidePromptAdjustment,
93 | segment.IntroEnd);
94 | segment.IntroEnd -= config.SecondsOfIntroToPlay;
95 |
96 | return segment;
97 | }
98 | catch (KeyNotFoundException)
99 | {
100 | return null;
101 | }
102 | }
103 |
104 | ///
105 | /// Erases all previously discovered introduction timestamps.
106 | ///
107 | /// Mode.
108 | /// Operation successful.
109 | /// No content.
110 | [Authorize(Policy = "RequiresElevation")]
111 | [HttpPost("Intros/EraseTimestamps")]
112 | public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode)
113 | {
114 | if (mode == AnalysisMode.Introduction)
115 | {
116 | Plugin.Instance!.Intros.Clear();
117 | }
118 | else if (mode == AnalysisMode.Credits)
119 | {
120 | Plugin.Instance!.Credits.Clear();
121 | }
122 |
123 | Plugin.Instance!.SaveTimestamps();
124 | return NoContent();
125 | }
126 |
127 | ///
128 | /// Get all introductions or credits. Only used by the end to end testing script.
129 | ///
130 | /// Mode.
131 | /// All timestamps have been returned.
132 | /// List of IntroWithMetadata objects.
133 | [Authorize(Policy = "RequiresElevation")]
134 | [HttpGet("Intros/All")]
135 | public ActionResult> GetAllTimestamps(
136 | [FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
137 | {
138 | List intros = new();
139 |
140 | var timestamps = mode == AnalysisMode.Introduction ?
141 | Plugin.Instance!.Intros :
142 | Plugin.Instance!.Credits;
143 |
144 | // Get metadata for all intros
145 | foreach (var intro in timestamps)
146 | {
147 | // Get the details of the item from Jellyfin
148 | var rawItem = Plugin.Instance!.GetItem(intro.Key);
149 | if (rawItem is not Episode episode)
150 | {
151 | throw new InvalidCastException("Unable to cast item id " + intro.Key + " to an Episode");
152 | }
153 |
154 | // Associate the metadata with the intro
155 | intros.Add(
156 | new IntroWithMetadata(
157 | episode.SeriesName,
158 | episode.AiredSeasonNumber ?? 0,
159 | episode.Name,
160 | intro.Value));
161 | }
162 |
163 | return intros;
164 | }
165 |
166 | ///
167 | /// Gets the user interface configuration.
168 | ///
169 | /// UserInterfaceConfiguration returned.
170 | /// UserInterfaceConfiguration.
171 | [Route("Intros/UserInterfaceConfiguration")]
172 | public ActionResult GetUserInterfaceConfiguration()
173 | {
174 | var config = Plugin.Instance!.Configuration;
175 | return new UserInterfaceConfiguration(
176 | config.SkipButtonVisible,
177 | config.SkipButtonIntroText,
178 | config.SkipButtonEndCreditsText);
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/TroubleshootingController.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Net.Mime;
3 | using System.Text;
4 | using MediaBrowser.Common;
5 | using Microsoft.AspNetCore.Authorization;
6 | using Microsoft.AspNetCore.Mvc;
7 | using Microsoft.Extensions.Logging;
8 |
9 | namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
10 |
11 | ///
12 | /// Troubleshooting controller.
13 | ///
14 | [Authorize(Policy = "RequiresElevation")]
15 | [ApiController]
16 | [Produces(MediaTypeNames.Application.Json)]
17 | [Route("IntroSkipper")]
18 | public class TroubleshootingController : ControllerBase
19 | {
20 | private readonly IApplicationHost _applicationHost;
21 | private readonly ILogger _logger;
22 |
23 | ///
24 | /// Initializes a new instance of the class.
25 | ///
26 | /// Application host.
27 | /// Logger.
28 | public TroubleshootingController(
29 | IApplicationHost applicationHost,
30 | ILogger logger)
31 | {
32 | _applicationHost = applicationHost;
33 | _logger = logger;
34 | }
35 |
36 | ///
37 | /// Gets a Markdown formatted support bundle.
38 | ///
39 | /// Support bundle created.
40 | /// Support bundle.
41 | [HttpGet("SupportBundle")]
42 | [Produces(MediaTypeNames.Text.Plain)]
43 | public ActionResult GetSupportBundle()
44 | {
45 | var config = Plugin.Instance!.Configuration;
46 | var bundle = new StringBuilder();
47 |
48 | bundle.Append("* Jellyfin version: ");
49 | bundle.Append(_applicationHost.ApplicationVersionString);
50 | bundle.Append('\n');
51 |
52 | var version = Plugin.Instance!.Version.ToString(3);
53 |
54 | try
55 | {
56 | var commit = Plugin.Instance!.GetCommit();
57 | if (!string.IsNullOrWhiteSpace(commit))
58 | {
59 | version += string.Concat("+", commit.AsSpan(0, 12));
60 | }
61 | }
62 | catch (Exception ex)
63 | {
64 | _logger.LogWarning("Unable to append commit to version: {Exception}", ex);
65 | }
66 |
67 | bundle.Append("* Plugin version: ");
68 | bundle.Append(version);
69 | bundle.Append('\n');
70 |
71 | bundle.Append("* Queue contents: ");
72 | bundle.Append(Plugin.Instance!.TotalQueued);
73 | bundle.Append(" episodes, ");
74 | bundle.Append(Plugin.Instance!.TotalSeasons);
75 | bundle.Append(" seasons\n");
76 |
77 | bundle.Append("* Warnings: `");
78 | bundle.Append(WarningManager.GetWarnings());
79 | bundle.Append("`\n");
80 |
81 | bundle.Append(FFmpegWrapper.GetChromaprintLogs());
82 |
83 | return bundle.ToString();
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Globalization;
4 | using System.Net.Mime;
5 | using Microsoft.AspNetCore.Authorization;
6 | using Microsoft.AspNetCore.Mvc;
7 | using Microsoft.Extensions.Logging;
8 |
9 | namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
10 |
11 | ///
12 | /// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis.
13 | ///
14 | [Authorize(Policy = "RequiresElevation")]
15 | [ApiController]
16 | [Produces(MediaTypeNames.Application.Json)]
17 | [Route("Intros")]
18 | public class VisualizationController : ControllerBase
19 | {
20 | private readonly ILogger _logger;
21 |
22 | ///
23 | /// Initializes a new instance of the class.
24 | ///
25 | /// Logger.
26 | public VisualizationController(ILogger logger)
27 | {
28 | _logger = logger;
29 | }
30 |
31 | ///
32 | /// Returns all show names and seasons.
33 | ///
34 | /// Dictionary of show names to a list of season names.
35 | [HttpGet("Shows")]
36 | public ActionResult>> GetShowSeasons()
37 | {
38 | _logger.LogDebug("Returning season names by series");
39 |
40 | var showSeasons = new Dictionary>();
41 |
42 | // Loop through all seasons in the analysis queue
43 | foreach (var kvp in Plugin.Instance!.QueuedMediaItems)
44 | {
45 | // Check that this season contains at least one episode.
46 | var episodes = kvp.Value;
47 | if (episodes is null || episodes.Count == 0)
48 | {
49 | _logger.LogDebug("Skipping season {Id} (null or empty)", kvp.Key);
50 | continue;
51 | }
52 |
53 | // Peek at the top episode from this season and store the series name and season number.
54 | var first = episodes[0];
55 | var series = first.SeriesName;
56 | var season = GetSeasonName(first);
57 |
58 | // Validate the series and season before attempting to store it.
59 | if (string.IsNullOrWhiteSpace(series) || string.IsNullOrWhiteSpace(season))
60 | {
61 | _logger.LogDebug("Skipping season {Id} (no name or number)", kvp.Key);
62 | continue;
63 | }
64 |
65 | // TryAdd is used when adding the HashSet since it is a no-op if one was already created for this series.
66 | showSeasons.TryAdd(series, new HashSet());
67 | showSeasons[series].Add(season);
68 | }
69 |
70 | return showSeasons;
71 | }
72 |
73 | ///
74 | /// Returns the names and unique identifiers of all episodes in the provided season.
75 | ///
76 | /// Show name.
77 | /// Season name.
78 | /// List of episode titles.
79 | [HttpGet("Show/{Series}/{Season}")]
80 | public ActionResult> GetSeasonEpisodes(
81 | [FromRoute] string series,
82 | [FromRoute] string season)
83 | {
84 | var visualEpisodes = new List();
85 |
86 | if (!LookupSeasonByName(series, season, out var episodes))
87 | {
88 | return NotFound();
89 | }
90 |
91 | foreach (var e in episodes)
92 | {
93 | visualEpisodes.Add(new EpisodeVisualization(e.EpisodeId, e.Name));
94 | }
95 |
96 | return visualEpisodes;
97 | }
98 |
99 | ///
100 | /// Fingerprint the provided episode and returns the uncompressed fingerprint data points.
101 | ///
102 | /// Episode id.
103 | /// Read only collection of fingerprint points.
104 | [HttpGet("Episode/{Id}/Chromaprint")]
105 | public ActionResult GetEpisodeFingerprint([FromRoute] Guid id)
106 | {
107 | // Search through all queued episodes to find the requested id
108 | foreach (var season in Plugin.Instance!.QueuedMediaItems)
109 | {
110 | foreach (var needle in season.Value)
111 | {
112 | if (needle.EpisodeId == id)
113 | {
114 | return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction);
115 | }
116 | }
117 | }
118 |
119 | return NotFound();
120 | }
121 |
122 | ///
123 | /// Erases all timestamps for the provided season.
124 | ///
125 | /// Show name.
126 | /// Season name.
127 | /// Season timestamps erased.
128 | /// Unable to find season in provided series.
129 | /// No content.
130 | [HttpDelete("Show/{Series}/{Season}")]
131 | public ActionResult EraseSeason([FromRoute] string series, [FromRoute] string season)
132 | {
133 | if (!LookupSeasonByName(series, season, out var episodes))
134 | {
135 | return NotFound();
136 | }
137 |
138 | _logger.LogInformation("Erasing timestamps for {Series} {Season} at user request", series, season);
139 |
140 | foreach (var e in episodes)
141 | {
142 | Plugin.Instance!.Intros.Remove(e.EpisodeId);
143 | }
144 |
145 | Plugin.Instance!.SaveTimestamps();
146 |
147 | return NoContent();
148 | }
149 |
150 | ///
151 | /// Updates the timestamps for the provided episode.
152 | ///
153 | /// Episode ID to update timestamps for.
154 | /// New introduction start and end times.
155 | /// New introduction timestamps saved.
156 | /// No content.
157 | [HttpPost("Episode/{Id}/UpdateIntroTimestamps")]
158 | public ActionResult UpdateTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps)
159 | {
160 | var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd);
161 | Plugin.Instance!.Intros[id] = new Intro(id, tr);
162 | Plugin.Instance!.SaveTimestamps();
163 |
164 | return NoContent();
165 | }
166 |
167 | private string GetSeasonName(QueuedEpisode episode)
168 | {
169 | return "Season " + episode.SeasonNumber.ToString(CultureInfo.InvariantCulture);
170 | }
171 |
172 | ///
173 | /// Lookup a named season of a series and return all queued episodes.
174 | ///
175 | /// Series name.
176 | /// Season name.
177 | /// Episodes.
178 | /// Boolean indicating if the requested season was found.
179 | private bool LookupSeasonByName(string series, string season, out List episodes)
180 | {
181 | foreach (var queuedEpisodes in Plugin.Instance!.QueuedMediaItems)
182 | {
183 | var first = queuedEpisodes.Value[0];
184 | var firstSeasonName = GetSeasonName(first);
185 |
186 | // Assert that the queued episode series and season are equal to what was requested
187 | if (
188 | !string.Equals(first.SeriesName, series, StringComparison.OrdinalIgnoreCase) ||
189 | !string.Equals(firstSeasonName, season, StringComparison.OrdinalIgnoreCase))
190 | {
191 | continue;
192 | }
193 |
194 | episodes = queuedEpisodes.Value;
195 | return true;
196 | }
197 |
198 | episodes = new List();
199 | return false;
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisMode.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
2 |
3 | ///
4 | /// Type of media file analysis to perform.
5 | ///
6 | public enum AnalysisMode
7 | {
8 | ///
9 | /// Detect introduction sequences.
10 | ///
11 | Introduction,
12 |
13 | ///
14 | /// Detect credits.
15 | ///
16 | Credits,
17 | }
18 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
2 |
3 | ///
4 | /// A frame of video that partially (or entirely) consists of black pixels.
5 | ///
6 | public class BlackFrame
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | /// Percentage of the frame that is black.
12 | /// Time this frame appears at.
13 | public BlackFrame(int percent, double time)
14 | {
15 | Percentage = percent;
16 | Time = time;
17 | }
18 |
19 | ///
20 | /// Gets or sets the percentage of the frame that is black.
21 | ///
22 | public int Percentage { get; set; }
23 |
24 | ///
25 | /// Gets or sets the time (in seconds) this frame appeared at.
26 | ///
27 | public double Time { get; set; }
28 | }
29 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
2 |
3 | ///
4 | /// Taken from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL.
5 | ///
6 | public enum EdlAction
7 | {
8 | ///
9 | /// Do not create EDL files.
10 | ///
11 | None = -1,
12 |
13 | ///
14 | /// Completely remove the intro from playback as if it was never in the original video.
15 | ///
16 | Cut,
17 |
18 | ///
19 | /// Mute audio, continue playback.
20 | ///
21 | Mute,
22 |
23 | ///
24 | /// Inserts a new scene marker.
25 | ///
26 | SceneMarker,
27 |
28 | ///
29 | /// Automatically skip the intro once during playback.
30 | ///
31 | CommercialBreak,
32 |
33 | ///
34 | /// Show a skip button.
35 | ///
36 | Intro,
37 | }
38 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Data/EpisodeVisualization.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
4 |
5 | ///
6 | /// Episode name and internal ID as returned by the visualization controller.
7 | ///
8 | public class EpisodeVisualization
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | /// Episode id.
14 | /// Episode name.
15 | public EpisodeVisualization(Guid id, string name)
16 | {
17 | Id = id;
18 | Name = name;
19 | }
20 |
21 | ///
22 | /// Gets the id.
23 | ///
24 | public Guid Id { get; private set; }
25 |
26 | ///
27 | /// Gets the name.
28 | ///
29 | public string Name { get; private set; } = string.Empty;
30 | }
31 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Data/FingerprintException.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
4 |
5 | ///
6 | /// Exception raised when an error is encountered analyzing audio.
7 | ///
8 | public class FingerprintException : Exception
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | public FingerprintException()
14 | {
15 | }
16 |
17 | ///
18 | /// Initializes a new instance of the class.
19 | ///
20 | /// Exception message.
21 | public FingerprintException(string message) : base(message)
22 | {
23 | }
24 |
25 | ///
26 | /// Initializes a new instance of the class.
27 | ///
28 | /// Exception message.
29 | /// Inner exception.
30 | public FingerprintException(string message, Exception inner) : base(message, inner)
31 | {
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Text.Json.Serialization;
3 |
4 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
5 |
6 | ///
7 | /// Result of fingerprinting and analyzing two episodes in a season.
8 | /// All times are measured in seconds relative to the beginning of the media file.
9 | ///
10 | public class Intro
11 | {
12 | ///
13 | /// Initializes a new instance of the class.
14 | ///
15 | /// Episode.
16 | /// Introduction time range.
17 | public Intro(Guid episode, TimeRange intro)
18 | {
19 | EpisodeId = episode;
20 | IntroStart = intro.Start;
21 | IntroEnd = intro.End;
22 | }
23 |
24 | ///
25 | /// Initializes a new instance of the class.
26 | ///
27 | /// Episode.
28 | public Intro(Guid episode)
29 | {
30 | EpisodeId = episode;
31 | IntroStart = 0;
32 | IntroEnd = 0;
33 | }
34 |
35 | ///
36 | /// Initializes a new instance of the class.
37 | ///
38 | /// intro.
39 | public Intro(Intro intro)
40 | {
41 | EpisodeId = intro.EpisodeId;
42 | IntroStart = intro.IntroStart;
43 | IntroEnd = intro.IntroEnd;
44 | }
45 |
46 | ///
47 | /// Initializes a new instance of the class.
48 | ///
49 | public Intro()
50 | {
51 | }
52 |
53 | ///
54 | /// Gets or sets the Episode ID.
55 | ///
56 | public Guid EpisodeId { get; set; }
57 |
58 | ///
59 | /// Gets a value indicating whether this introduction is valid or not.
60 | /// Invalid results must not be returned through the API.
61 | ///
62 | public bool Valid => IntroEnd > 0;
63 |
64 | ///
65 | /// Gets the duration of this intro.
66 | ///
67 | [JsonIgnore]
68 | public double Duration => IntroEnd - IntroStart;
69 |
70 | ///
71 | /// Gets or sets the introduction sequence start time.
72 | ///
73 | public double IntroStart { get; set; }
74 |
75 | ///
76 | /// Gets or sets the introduction sequence end time.
77 | ///
78 | public double IntroEnd { get; set; }
79 |
80 | ///
81 | /// Gets or sets the recommended time to display the skip intro prompt.
82 | ///
83 | public double ShowSkipPromptAt { get; set; }
84 |
85 | ///
86 | /// Gets or sets the recommended time to hide the skip intro prompt.
87 | ///
88 | public double HideSkipPromptAt { get; set; }
89 |
90 | ///
91 | /// Convert this Intro object to a Kodi compatible EDL entry.
92 | ///
93 | /// User specified configuration EDL action.
94 | /// String.
95 | public string ToEdl(EdlAction action)
96 | {
97 | if (action == EdlAction.None)
98 | {
99 | throw new ArgumentException("Cannot serialize an EdlAction of None");
100 | }
101 |
102 | var start = Math.Round(IntroStart, 2);
103 | var end = Math.Round(IntroEnd, 2);
104 |
105 | return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0} {1} {2}", start, end, (int)action);
106 | }
107 | }
108 |
109 | ///
110 | /// An Intro class with episode metadata. Only used in end to end testing programs.
111 | ///
112 | public class IntroWithMetadata : Intro
113 | {
114 | ///
115 | /// Initializes a new instance of the class.
116 | ///
117 | /// Series name.
118 | /// Season number.
119 | /// Episode title.
120 | /// Intro timestamps.
121 | public IntroWithMetadata(string series, int season, string title, Intro intro)
122 | {
123 | Series = series;
124 | Season = season;
125 | Title = title;
126 |
127 | EpisodeId = intro.EpisodeId;
128 | IntroStart = intro.IntroStart;
129 | IntroEnd = intro.IntroEnd;
130 | }
131 |
132 | ///
133 | /// Gets or sets the series name of the TV episode associated with this intro.
134 | ///
135 | public string Series { get; set; }
136 |
137 | ///
138 | /// Gets or sets the season number of the TV episode associated with this intro.
139 | ///
140 | public int Season { get; set; }
141 |
142 | ///
143 | /// Gets or sets the title of the TV episode associated with this intro.
144 | ///
145 | public string Title { get; set; }
146 | }
147 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Data/PluginWarning.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
2 |
3 | using System;
4 |
5 | ///
6 | /// Support bundle warning.
7 | ///
8 | [Flags]
9 | public enum PluginWarning
10 | {
11 | ///
12 | /// No warnings have been added.
13 | ///
14 | None = 0,
15 |
16 | ///
17 | /// Attempted to add skip button to web interface, but was unable to.
18 | ///
19 | UnableToAddSkipButton = 1,
20 |
21 | ///
22 | /// At least one media file on the server was unable to be fingerprinted by Chromaprint.
23 | ///
24 | InvalidChromaprintFingerprint = 2,
25 |
26 | ///
27 | /// The version of ffmpeg installed on the system is not compatible with the plugin.
28 | ///
29 | IncompatibleFFmpegBuild = 4,
30 | }
31 |
32 | ///
33 | /// Warning manager.
34 | ///
35 | public static class WarningManager
36 | {
37 | private static PluginWarning warnings;
38 |
39 | ///
40 | /// Set warning.
41 | ///
42 | /// Warning.
43 | public static void SetFlag(PluginWarning warning)
44 | {
45 | warnings |= warning;
46 | }
47 |
48 | ///
49 | /// Clear warnings.
50 | ///
51 | public static void Clear()
52 | {
53 | warnings = PluginWarning.None;
54 | }
55 |
56 | ///
57 | /// Get warnings.
58 | ///
59 | /// Warnings.
60 | public static string GetWarnings()
61 | {
62 | return warnings.ToString();
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
4 |
5 | ///
6 | /// Episode queued for analysis.
7 | ///
8 | public class QueuedEpisode
9 | {
10 | ///
11 | /// Gets or sets the series name.
12 | ///
13 | public string SeriesName { get; set; } = string.Empty;
14 |
15 | ///
16 | /// Gets or sets the season number.
17 | ///
18 | public int SeasonNumber { get; set; }
19 |
20 | ///
21 | /// Gets or sets the episode id.
22 | ///
23 | public Guid EpisodeId { get; set; }
24 |
25 | ///
26 | /// Gets or sets the full path to episode.
27 | ///
28 | public string Path { get; set; } = string.Empty;
29 |
30 | ///
31 | /// Gets or sets the name of the episode.
32 | ///
33 | public string Name { get; set; } = string.Empty;
34 |
35 | ///
36 | /// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
37 | ///
38 | public int IntroFingerprintEnd { get; set; }
39 |
40 | ///
41 | /// Gets or sets the timestamp (in seconds) to start looking for end credits at.
42 | ///
43 | public int CreditsFingerprintStart { get; set; }
44 |
45 | ///
46 | /// Gets or sets the total duration of this media file (in seconds).
47 | ///
48 | public int Duration { get; set; }
49 | }
50 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 |
4 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
5 |
6 | #pragma warning disable CA1036 // Override methods on comparable types
7 |
8 | ///
9 | /// Range of contiguous time.
10 | ///
11 | public class TimeRange : IComparable
12 | {
13 | ///
14 | /// Initializes a new instance of the class.
15 | ///
16 | public TimeRange()
17 | {
18 | Start = 0;
19 | End = 0;
20 | }
21 |
22 | ///
23 | /// Initializes a new instance of the class.
24 | ///
25 | /// Time range start.
26 | /// Time range end.
27 | public TimeRange(double start, double end)
28 | {
29 | Start = start;
30 | End = end;
31 | }
32 |
33 | ///
34 | /// Initializes a new instance of the class.
35 | ///
36 | /// Original TimeRange.
37 | public TimeRange(TimeRange original)
38 | {
39 | Start = original.Start;
40 | End = original.End;
41 | }
42 |
43 | ///
44 | /// Gets or sets the time range start (in seconds).
45 | ///
46 | public double Start { get; set; }
47 |
48 | ///
49 | /// Gets or sets the time range end (in seconds).
50 | ///
51 | public double End { get; set; }
52 |
53 | ///
54 | /// Gets the duration of this time range (in seconds).
55 | ///
56 | public double Duration => End - Start;
57 |
58 | ///
59 | /// Compare TimeRange durations.
60 | ///
61 | /// Object to compare with.
62 | /// int.
63 | public int CompareTo(object? obj)
64 | {
65 | if (!(obj is TimeRange tr))
66 | {
67 | throw new ArgumentException("obj must be a TimeRange");
68 | }
69 |
70 | return tr.Duration.CompareTo(Duration);
71 | }
72 |
73 | ///
74 | /// Tests if this TimeRange object intersects the provided TimeRange.
75 | ///
76 | /// Second TimeRange object to test.
77 | /// true if tr intersects the current TimeRange, false otherwise.
78 | public bool Intersects(TimeRange tr)
79 | {
80 | return
81 | (Start < tr.Start && tr.Start < End) ||
82 | (Start < tr.End && tr.End < End);
83 | }
84 | }
85 |
86 | #pragma warning restore CA1036
87 |
88 | ///
89 | /// Time range helpers.
90 | ///
91 | public static class TimeRangeHelpers
92 | {
93 | ///
94 | /// Finds the longest contiguous time range.
95 | ///
96 | /// Sorted timestamps to search.
97 | /// Maximum distance permitted between contiguous timestamps.
98 | /// The longest contiguous time range (if one was found), or null (if none was found).
99 | public static TimeRange? FindContiguous(double[] times, double maximumDistance)
100 | {
101 | if (times.Length == 0)
102 | {
103 | return null;
104 | }
105 |
106 | Array.Sort(times);
107 |
108 | var ranges = new List();
109 | var currentRange = new TimeRange(times[0], times[0]);
110 |
111 | // For all provided timestamps, check if it is contiguous with its neighbor.
112 | for (var i = 0; i < times.Length - 1; i++)
113 | {
114 | var current = times[i];
115 | var next = times[i + 1];
116 |
117 | if (next - current <= maximumDistance)
118 | {
119 | currentRange.End = next;
120 | continue;
121 | }
122 |
123 | ranges.Add(new TimeRange(currentRange));
124 | currentRange = new TimeRange(next, next);
125 | }
126 |
127 | // Find and return the longest contiguous range.
128 | ranges.Sort();
129 |
130 | return (ranges.Count > 0) ? ranges[0] : null;
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
2 |
3 | using System;
4 | using System.Collections.ObjectModel;
5 | using System.IO;
6 | using Microsoft.Extensions.Logging;
7 |
8 | ///
9 | /// Update EDL files associated with a list of episodes.
10 | ///
11 | public static class EdlManager
12 | {
13 | private static ILogger? _logger;
14 |
15 | ///
16 | /// Initialize EDLManager with a logger.
17 | ///
18 | /// ILogger.
19 | public static void Initialize(ILogger logger)
20 | {
21 | _logger = logger;
22 | }
23 |
24 | ///
25 | /// Logs the configuration that will be used during EDL file creation.
26 | ///
27 | public static void LogConfiguration()
28 | {
29 | if (_logger is null)
30 | {
31 | throw new InvalidOperationException("Logger must not be null");
32 | }
33 |
34 | var config = Plugin.Instance!.Configuration;
35 |
36 | if (config.EdlAction == EdlAction.None)
37 | {
38 | _logger.LogDebug("EDL action: None - taking no further action");
39 | return;
40 | }
41 |
42 | _logger.LogDebug("EDL action: {Action}", config.EdlAction);
43 | _logger.LogDebug("Regenerate EDL files: {Regenerate}", config.RegenerateEdlFiles);
44 | }
45 |
46 | ///
47 | /// If the EDL action is set to a value other than None, update EDL files for the provided episodes.
48 | ///
49 | /// Episodes to update EDL files for.
50 | public static void UpdateEDLFiles(ReadOnlyCollection episodes)
51 | {
52 | var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles;
53 | var action = Plugin.Instance!.Configuration.EdlAction;
54 | if (action == EdlAction.None)
55 | {
56 | _logger?.LogDebug("EDL action is set to none, not updating EDL files");
57 | return;
58 | }
59 |
60 | _logger?.LogDebug("Updating EDL files with action {Action}", action);
61 |
62 | foreach (var episode in episodes)
63 | {
64 | var id = episode.EpisodeId;
65 |
66 | if (!Plugin.Instance!.Intros.TryGetValue(id, out var intro))
67 | {
68 | _logger?.LogDebug("Episode {Id} did not have an introduction, skipping", id);
69 | continue;
70 | }
71 | else if (!intro.Valid)
72 | {
73 | _logger?.LogDebug("Episode {Id} did not have a valid introduction, skipping", id);
74 | continue;
75 | }
76 |
77 | var edlPath = GetEdlPath(Plugin.Instance!.GetItemPath(id));
78 |
79 | _logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath);
80 |
81 | if (!regenerate && File.Exists(edlPath))
82 | {
83 | _logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath);
84 | continue;
85 | }
86 |
87 | File.WriteAllText(edlPath, intro.ToEdl(action));
88 | }
89 | }
90 |
91 | ///
92 | /// Given the path to an episode, return the path to the associated EDL file.
93 | ///
94 | /// Full path to episode.
95 | /// Full path to EDL file.
96 | public static string GetEdlPath(string mediaPath)
97 | {
98 | return Path.ChangeExtension(mediaPath, "edl");
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.Threading.Tasks;
4 | using MediaBrowser.Controller.Library;
5 | using MediaBrowser.Controller.Plugins;
6 | using Microsoft.Extensions.Logging;
7 |
8 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
9 |
10 | ///
11 | /// Server entrypoint.
12 | ///
13 | public class Entrypoint : IServerEntryPoint
14 | {
15 | private readonly IUserManager _userManager;
16 | private readonly IUserViewManager _userViewManager;
17 | private readonly ILibraryManager _libraryManager;
18 | private readonly ILogger _logger;
19 | private readonly ILoggerFactory _loggerFactory;
20 |
21 | ///
22 | /// Initializes a new instance of the class.
23 | ///
24 | /// User manager.
25 | /// User view manager.
26 | /// Library manager.
27 | /// Logger.
28 | /// Logger factory.
29 | public Entrypoint(
30 | IUserManager userManager,
31 | IUserViewManager userViewManager,
32 | ILibraryManager libraryManager,
33 | ILogger logger,
34 | ILoggerFactory loggerFactory)
35 | {
36 | _userManager = userManager;
37 | _userViewManager = userViewManager;
38 | _libraryManager = libraryManager;
39 | _logger = logger;
40 | _loggerFactory = loggerFactory;
41 | }
42 |
43 | ///
44 | /// Registers event handler.
45 | ///
46 | /// Task.
47 | public Task RunAsync()
48 | {
49 | FFmpegWrapper.Logger = _logger;
50 |
51 | // TODO: when a new item is added to the server, immediately analyze the season it belongs to
52 | // instead of waiting for the next task interval. The task start should be debounced by a few seconds.
53 |
54 | try
55 | {
56 | // Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible
57 | _logger.LogInformation("Running startup enqueue");
58 | var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager);
59 | queueManager.GetMediaItems();
60 | }
61 | catch (Exception ex)
62 | {
63 | _logger.LogError("Unable to run startup enqueue: {Exception}", ex);
64 | }
65 |
66 | return Task.CompletedTask;
67 | }
68 |
69 | ///
70 | /// Dispose.
71 | ///
72 | public void Dispose()
73 | {
74 | Dispose(true);
75 | GC.SuppressFinalize(this);
76 | }
77 |
78 | ///
79 | /// Protected dispose.
80 | ///
81 | /// Dispose.
82 | protected virtual void Dispose(bool dispose)
83 | {
84 | if (!dispose)
85 | {
86 | return;
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs:
--------------------------------------------------------------------------------
1 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
2 |
3 | using System;
4 | using System.Collections.ObjectModel;
5 | using System.Threading;
6 | using System.Threading.Tasks;
7 | using MediaBrowser.Controller.Library;
8 | using Microsoft.Extensions.Logging;
9 |
10 | ///
11 | /// Common code shared by all media item analyzer tasks.
12 | ///
13 | public class BaseItemAnalyzerTask
14 | {
15 | private readonly AnalysisMode _analysisMode;
16 |
17 | private readonly ILogger _logger;
18 |
19 | private readonly ILoggerFactory _loggerFactory;
20 |
21 | private readonly ILibraryManager _libraryManager;
22 |
23 | ///
24 | /// Initializes a new instance of the class.
25 | ///
26 | /// Analysis mode.
27 | /// Task logger.
28 | /// Logger factory.
29 | /// Library manager.
30 | public BaseItemAnalyzerTask(
31 | AnalysisMode mode,
32 | ILogger logger,
33 | ILoggerFactory loggerFactory,
34 | ILibraryManager libraryManager)
35 | {
36 | _analysisMode = mode;
37 | _logger = logger;
38 | _loggerFactory = loggerFactory;
39 | _libraryManager = libraryManager;
40 |
41 | if (mode == AnalysisMode.Introduction)
42 | {
43 | EdlManager.Initialize(_logger);
44 | }
45 | }
46 |
47 | ///
48 | /// Analyze all media items on the server.
49 | ///
50 | /// Progress.
51 | /// Cancellation token.
52 | public void AnalyzeItems(
53 | IProgress progress,
54 | CancellationToken cancellationToken)
55 | {
56 | var queueManager = new QueueManager(
57 | _loggerFactory.CreateLogger(),
58 | _libraryManager);
59 |
60 | var queue = queueManager.GetMediaItems();
61 |
62 | var totalQueued = 0;
63 | foreach (var kvp in queue)
64 | {
65 | totalQueued += kvp.Value.Count;
66 | }
67 |
68 | if (totalQueued == 0)
69 | {
70 | throw new FingerprintException(
71 | "No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly.");
72 | }
73 |
74 | if (this._analysisMode == AnalysisMode.Introduction)
75 | {
76 | EdlManager.LogConfiguration();
77 | }
78 |
79 | var totalProcessed = 0;
80 | var options = new ParallelOptions()
81 | {
82 | MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
83 | };
84 |
85 | Parallel.ForEach(queue, options, (season) =>
86 | {
87 | var writeEdl = false;
88 |
89 | // Since the first run of the task can run for multiple hours, ensure that none
90 | // of the current media items were deleted from Jellyfin since the task was started.
91 | var (episodes, unanalyzed) = queueManager.VerifyQueue(
92 | season.Value.AsReadOnly(),
93 | this._analysisMode);
94 |
95 | if (episodes.Count == 0)
96 | {
97 | return;
98 | }
99 |
100 | var first = episodes[0];
101 |
102 | if (!unanalyzed)
103 | {
104 | _logger.LogDebug(
105 | "All episodes in {Name} season {Season} have already been analyzed",
106 | first.SeriesName,
107 | first.SeasonNumber);
108 |
109 | return;
110 | }
111 |
112 | try
113 | {
114 | if (cancellationToken.IsCancellationRequested)
115 | {
116 | return;
117 | }
118 |
119 | var analyzed = AnalyzeItems(episodes, cancellationToken);
120 | Interlocked.Add(ref totalProcessed, analyzed);
121 |
122 | writeEdl = analyzed > 0 || Plugin.Instance!.Configuration.RegenerateEdlFiles;
123 | }
124 | catch (FingerprintException ex)
125 | {
126 | _logger.LogWarning(
127 | "Unable to analyze {Series} season {Season}: unable to fingerprint: {Ex}",
128 | first.SeriesName,
129 | first.SeasonNumber,
130 | ex);
131 | }
132 |
133 | if (
134 | writeEdl &&
135 | Plugin.Instance!.Configuration.EdlAction != EdlAction.None &&
136 | _analysisMode == AnalysisMode.Introduction)
137 | {
138 | EdlManager.UpdateEDLFiles(episodes);
139 | }
140 |
141 | progress.Report((totalProcessed * 100) / totalQueued);
142 | });
143 |
144 | if (
145 | _analysisMode == AnalysisMode.Introduction &&
146 | Plugin.Instance!.Configuration.RegenerateEdlFiles)
147 | {
148 | _logger.LogInformation("Turning EDL file regeneration flag off");
149 | Plugin.Instance!.Configuration.RegenerateEdlFiles = false;
150 | Plugin.Instance!.SaveConfiguration();
151 | }
152 | }
153 |
154 | ///
155 | /// Analyze a group of media items for skippable segments.
156 | ///
157 | /// Media items to analyze.
158 | /// Cancellation token.
159 | /// Number of items that were successfully analyzed.
160 | private int AnalyzeItems(
161 | ReadOnlyCollection items,
162 | CancellationToken cancellationToken)
163 | {
164 | var totalItems = items.Count;
165 |
166 | // Only analyze specials (season 0) if the user has opted in.
167 | var first = items[0];
168 | if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
169 | {
170 | return 0;
171 | }
172 |
173 | _logger.LogInformation(
174 | "Analyzing {Count} files from {Name} season {Season}",
175 | items.Count,
176 | first.SeriesName,
177 | first.SeasonNumber);
178 |
179 | var analyzers = new Collection();
180 |
181 | analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger()));
182 | analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger()));
183 |
184 | if (this._analysisMode == AnalysisMode.Credits)
185 | {
186 | analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger()));
187 | }
188 |
189 | // Use each analyzer to find skippable ranges in all media files, removing successfully
190 | // analyzed items from the queue.
191 | foreach (var analyzer in analyzers)
192 | {
193 | items = analyzer.AnalyzeMediaFiles(items, this._analysisMode, cancellationToken);
194 | }
195 |
196 | return totalItems;
197 | }
198 | }
199 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Threading;
4 | using System.Threading.Tasks;
5 | using MediaBrowser.Controller.Library;
6 | using MediaBrowser.Model.Tasks;
7 | using Microsoft.Extensions.Logging;
8 |
9 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
10 |
11 | ///
12 | /// Analyze all television episodes for credits.
13 | /// TODO: analyze all media files.
14 | ///
15 | public class DetectCreditsTask : IScheduledTask
16 | {
17 | private readonly ILoggerFactory _loggerFactory;
18 |
19 | private readonly ILibraryManager _libraryManager;
20 |
21 | ///
22 | /// Initializes a new instance of the class.
23 | ///
24 | /// Logger factory.
25 | /// Library manager.
26 | public DetectCreditsTask(
27 | ILoggerFactory loggerFactory,
28 | ILibraryManager libraryManager)
29 | {
30 | _loggerFactory = loggerFactory;
31 | _libraryManager = libraryManager;
32 | }
33 |
34 | ///
35 | /// Gets the task name.
36 | ///
37 | public string Name => "Detect Credits";
38 |
39 | ///
40 | /// Gets the task category.
41 | ///
42 | public string Category => "Intro Skipper";
43 |
44 | ///
45 | /// Gets the task description.
46 | ///
47 | public string Description => "Analyzes the audio and video of all television episodes to find credits.";
48 |
49 | ///
50 | /// Gets the task key.
51 | ///
52 | public string Key => "CPBIntroSkipperDetectCredits";
53 |
54 | ///
55 | /// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
56 | ///
57 | /// Task progress.
58 | /// Cancellation token.
59 | /// Task.
60 | public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken)
61 | {
62 | if (_libraryManager is null)
63 | {
64 | throw new InvalidOperationException("Library manager was null");
65 | }
66 |
67 | var baseAnalyzer = new BaseItemAnalyzerTask(
68 | AnalysisMode.Credits,
69 | _loggerFactory.CreateLogger(),
70 | _loggerFactory,
71 | _libraryManager);
72 |
73 | baseAnalyzer.AnalyzeItems(progress, cancellationToken);
74 |
75 | return Task.CompletedTask;
76 | }
77 |
78 | ///
79 | /// Get task triggers.
80 | ///
81 | /// Task triggers.
82 | public IEnumerable GetDefaultTriggers()
83 | {
84 | return Array.Empty();
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectIntroductionsTask.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Threading;
4 | using System.Threading.Tasks;
5 | using MediaBrowser.Controller.Library;
6 | using MediaBrowser.Model.Tasks;
7 | using Microsoft.Extensions.Logging;
8 |
9 | namespace ConfusedPolarBear.Plugin.IntroSkipper;
10 |
11 | ///
12 | /// Analyze all television episodes for introduction sequences.
13 | ///
14 | public class DetectIntroductionsTask : IScheduledTask
15 | {
16 | private readonly ILoggerFactory _loggerFactory;
17 |
18 | private readonly ILibraryManager _libraryManager;
19 |
20 | ///
21 | /// Initializes a new instance of the class.
22 | ///
23 | /// Logger factory.
24 | /// Library manager.
25 | public DetectIntroductionsTask(
26 | ILoggerFactory loggerFactory,
27 | ILibraryManager libraryManager)
28 | {
29 | _loggerFactory = loggerFactory;
30 | _libraryManager = libraryManager;
31 | }
32 |
33 | ///
34 | /// Gets the task name.
35 | ///
36 | public string Name => "Detect Introductions";
37 |
38 | ///
39 | /// Gets the task category.
40 | ///
41 | public string Category => "Intro Skipper";
42 |
43 | ///
44 | /// Gets the task description.
45 | ///
46 | public string Description => "Analyzes the audio of all television episodes to find introduction sequences.";
47 |
48 | ///
49 | /// Gets the task key.
50 | ///
51 | public string Key => "CPBIntroSkipperDetectIntroductions";
52 |
53 | ///
54 | /// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
55 | ///
56 | /// Task progress.
57 | /// Cancellation token.
58 | /// Task.
59 | public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken)
60 | {
61 | if (_libraryManager is null)
62 | {
63 | throw new InvalidOperationException("Library manager was null");
64 | }
65 |
66 | var baseAnalyzer = new BaseItemAnalyzerTask(
67 | AnalysisMode.Introduction,
68 | _loggerFactory.CreateLogger(),
69 | _loggerFactory,
70 | _libraryManager);
71 |
72 | baseAnalyzer.AnalyzeItems(progress, cancellationToken);
73 |
74 | return Task.CompletedTask;
75 | }
76 |
77 | ///
78 | /// Get task triggers.
79 | ///
80 | /// Task triggers.
81 | public IEnumerable GetDefaultTriggers()
82 | {
83 | return new[]
84 | {
85 | new TaskTriggerInfo
86 | {
87 | Type = TaskTriggerInfo.TriggerDaily,
88 | TimeOfDayTicks = TimeSpan.FromHours(0).Ticks
89 | }
90 | };
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Intro Skipper (beta)
2 |
3 |
4 |

5 |
6 |
7 | Analyzes the audio of television episodes to detect and skip over intros.
8 |
9 | If you use the custom web interface on your server, you will be able to click a button to skip intros, like this:
10 |
11 | 
12 |
13 | However, if you want to use an unmodified installation of Jellyfin 10.8.z or use clients that do not use the web interface provided by the server, the plugin can be configured to automatically skip intros.
14 |
15 | ## System requirements
16 |
17 | * Jellyfin 10.8.4 (or newer)
18 | * Jellyfin's [fork](https://github.com/jellyfin/jellyfin-ffmpeg) of `ffmpeg` must be installed, version `5.0.1-5` or newer
19 | * `jellyfin/jellyfin` 10.8.z container: preinstalled
20 | * `linuxserver/jellyfin` 10.8.z container: preinstalled
21 | * Debian Linux based native installs: provided by the `jellyfin-ffmpeg5` package
22 | * MacOS native installs: build ffmpeg with chromaprint support ([instructions](#installation-instructions-for-macos))
23 |
24 | ## Introduction requirements
25 |
26 | Show introductions will only be detected if they are:
27 |
28 | * Located within the first 25% of an episode, or the first 10 minutes, whichever is smaller
29 | * Between 15 seconds and 2 minutes long
30 |
31 | Ending credits will only be detected if they are shorter than 4 minutes.
32 |
33 | All of these requirements can be customized as needed.
34 |
35 | ## Installation instructions
36 |
37 | ### Step 1: Install the modified web interface (optional)
38 | While this plugin is fully compatible with an unmodified version of Jellyfin 10.8.z, using a modified web interface allows you to click a button to skip intros. If you skip this step and do not use the modified web interface, you will have to enable the "Automatically skip intros" option in the plugin settings.
39 |
40 | Instructions on how to switch web interface versions are located [here](docs/web_interface.md).
41 |
42 | ### Step 2: Install the plugin
43 | 1. Add this plugin repository to your server: `https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/master/manifest.json`
44 | 2. Install the Intro Skipper plugin from the General section
45 | 3. Restart Jellyfin
46 | 4. If you did not install the modified web interface, enable automatic skipping
47 | 1. Go to Dashboard -> Plugins -> Intro Skipper
48 | 2. Check "Automatically skip intros" and click Save
49 | 5. Go to Dashboard -> Scheduled Tasks -> Analyze Episodes and click the play button
50 | 6. After a season has completed analyzing, play some episodes from it and observe the results
51 | 1. Status updates are logged before analyzing each season of a show
52 |
53 | ## Installation instructions for MacOS
54 |
55 | 1. Build ffmpeg with chromaprint support using brew:
56 |
57 | ```
58 | brew uninstall --force --ignore-dependencies ffmpeg
59 | brew install chromaprint amiaopensource/amiaos/decklinksdk
60 | brew tap homebrew-ffmpeg/ffmpeg
61 | brew install homebrew-ffmpeg/ffmpeg/ffmpeg --with-chromaprint
62 | brew link --overwrite ffmpeg
63 | ```
64 |
65 | 2. Retrieve ffmpeg path with `whereis ffmpeg` and use this path on Jellyfin under [encoding settings](http://localhost:8096/web/index.html#!/encodingsettings.html)
66 |
67 | 3. Follow the [installation instructions](#installation-instructions) above
68 |
69 | ## Documentation
70 |
71 | Documentation about how the API works can be found in [api.md](docs/api.md).
72 |
--------------------------------------------------------------------------------
/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG JELLYFIN_TAG=10.8.10
2 | FROM jellyfin/jellyfin:${JELLYFIN_TAG}
3 |
4 | COPY dist/ /jellyfin/jellyfin-web/
5 |
--------------------------------------------------------------------------------
/docker/README.md:
--------------------------------------------------------------------------------
1 | Build instructions for the `ghcr.io/confusedpolarbear/jellyfin-intro-skipper` container:
2 |
3 | 1. Clone `https://github.com/ConfusedPolarBear/jellyfin-web` and checkout the `intros` branch
4 | 2. Run `npm run build:production`
5 | 3. Copy the `dist` folder into this folder
6 | 4. Run `docker build .`
7 |
--------------------------------------------------------------------------------
/docs/api.md:
--------------------------------------------------------------------------------
1 | # API
2 |
3 | ## General
4 |
5 | The main API endpoint exposed by this plugin is `/Episode/{ItemId}/IntroTimestamps`. If an introduction was detected inside of a television episode, this endpoint will return the timestamps of that intro.
6 |
7 | An API version can be optionally selected by appending `/v{Version}` to the URL. If a version is not specified, version 1 will be selected.
8 |
9 | ## API version 1 (default)
10 |
11 | API version 1 was introduced with the initial alpha release of the plugin. It is accessible (via a `GET` request) on the following URLs:
12 | * `/Episode/{ItemId}/IntroTimestamps`
13 | * `/Episode/{ItemId}/IntroTimestamps/v1`
14 |
15 | Both of these endpoints require an authorization token to be provided.
16 |
17 | The possible status codes of this endpoint are:
18 | * `200 (OK)`: An introduction was detected for this item and the response is deserializable as JSON using the schema below.
19 | * `404 (Not Found)`: Either no introduction was detected for this item or it is not a television episode.
20 |
21 | JSON schema:
22 |
23 | ```jsonc
24 | {
25 | "EpisodeId": "{item id}", // Unique GUID for this item as provided by Jellyfin.
26 | "Valid": true, // Used internally to mark items that have intros. Should be ignored as it will always be true.
27 | "IntroStart": 100.5, // Start time (in seconds) of the introduction.
28 | "IntroEnd": 130.42, // End time (in seconds) of the introduction.
29 | "ShowSkipPromptAt": 95.5, // Recommended time to display an on-screen intro skip prompt to the user.
30 | "HideSkipPromptAt": 110.5 // Recommended time to hide the on-screen intro skip prompt.
31 | }
32 | ```
33 |
34 | The `ShowSkipPromptAt` and `HideSkipPromptAt` properties are derived from the start time of the introduction and are customizable by the user from the plugin's settings.
35 |
36 | ### Example curl command
37 |
38 | `curl` command to get introduction timestamps for the item with id `12345678901234567890123456789012`:
39 |
40 | ```shell
41 | curl http://127.0.0.1:8096/Episode/12345678901234567890123456789012/IntroTimestamps/v1 -H 'Authorization: MediaBrowser Token="98765432109876543210987654321098"'
42 | ```
43 |
44 | This returns the following JSON object:
45 | ```json
46 | {
47 | "EpisodeId": "12345678901234567890123456789012",
48 | "Valid": true,
49 | "IntroStart": 304,
50 | "IntroEnd": 397.48,
51 | "ShowSkipPromptAt": 299,
52 | "HideSkipPromptAt": 314
53 | }
54 | ```
55 |
--------------------------------------------------------------------------------
/docs/debug_logs.md:
--------------------------------------------------------------------------------
1 | # How to enable plugin debug logs
2 |
3 | 1. Browse to your Jellyfin config folder
4 | 2. Make a backup copy of `config/logging.default.json` before editing it
5 | 3. Open `config/logging.default.json` with a text editor. The top lines should look something like this:
6 |
7 | ```jsonc
8 | {
9 | "Serilog": {
10 | "MinimumLevel": {
11 | "Default": "Information",
12 | "Override": {
13 | "Microsoft": "Warning",
14 | "System": "Warning"
15 | }
16 | },
17 | // rest of file ommited for brevity
18 | }
19 | }
20 | ```
21 |
22 | 4. Inside the `Override` section, add a new entry for `ConfusedPolarBear` and set it to `Debug`. The modified file should now look like this:
23 |
24 | ```jsonc
25 | {
26 | "Serilog": {
27 | "MinimumLevel": {
28 | "Default": "Information",
29 | "Override": {
30 | "Microsoft": "Warning",
31 | "System": "Warning", // be sure to add the trailing comma after "Warning",
32 | "ConfusedPolarBear": "Debug" // newly added line
33 | }
34 | },
35 | // rest of file ommited for brevity
36 | }
37 | }
38 | ```
39 |
40 | 5. Save the file and restart Jellyfin
41 |
42 | ## How to enable verbose logs
43 |
44 | To enable verbose log messages, set the log level to `Verbose` instead of `Debug` in step 4.
45 |
--------------------------------------------------------------------------------
/docs/edl.md:
--------------------------------------------------------------------------------
1 | # EDL support
2 |
3 | The timestamps of discovered introductions can be written to [EDL](https://kodi.wiki/view/Edit_decision_list) files alongside your media files. EDL files are saved when:
4 | * Scanning an episode for the first time, or
5 | * If requested with the regenerate checkbox
6 |
7 | ## Configuration
8 |
9 | Jellyfin must have read/write access to your TV show libraries in order to make use of this feature.
10 |
11 | ## Usage
12 |
13 | To have the plugin create EDL files:
14 | 1. Change the EDL action from the default of None to any of the other supported EDL actions
15 | 2. Check the "Regenerate EDL files during next analysis" checkbox
16 | 1. If this option is not selected, only seasons with a newly analyzed episode will have EDL files created.
17 |
--------------------------------------------------------------------------------
/docs/native.md:
--------------------------------------------------------------------------------
1 | # Native installation
2 |
3 | ## Requirements
4 |
5 | * Jellyfin 10.8.0
6 | * Compiled [jellyfin-web](https://github.com/ConfusedPolarBear/jellyfin-web/tree/intros) interface with intro skip button
7 |
8 | ## Instructions
9 |
10 | 1. Download and extract the latest modified web interface from [GitHub actions](https://github.com/ConfusedPolarBear/intro-skipper/actions/workflows/container.yml)
11 | 1. Click the most recent action run
12 | 2. In the Artifacts section, click the `jellyfin-web-VERSION+COMMIT.tar.gz` link to download a pre-compiled copy of the web interface. This link will only work if you are signed into GitHub.
13 | 2. Make a backup of the original web interface
14 | 1. On Linux, the web interface is located in `/usr/share/jellyfin/web/`
15 | 2. On Windows, the web interface is located in `C:\Program Files\Jellyfin\Server\jellyfin-web`
16 | 3. Copy the contents of the `dist` folder you downloaded in step 1 into Jellyfin's web folder
17 | 4. Follow the plugin installation steps from the readme
18 |
--------------------------------------------------------------------------------
/docs/release.md:
--------------------------------------------------------------------------------
1 | # Release procedure
2 |
3 | ## Run tests
4 |
5 | 1. Run unit tests with `dotnet test`
6 | 2. Run end to end tests with `JELLYFIN_TOKEN=api_key_here python3 main.py`
7 |
8 | ## Release plugin
9 |
10 | 1. Run package plugin action and download bundle
11 | 2. Combine generated `manifest.json` with main plugin manifest
12 | 3. Test plugin manifest
13 | 1. Replace manifest URL with local IP address
14 | 2. Serve release ZIP and manifest with `python3 -m http.server`
15 | 3. Test updating plugin
16 | 4. Create release on GitHub with the following files:
17 | 1. Archived plugin DLL
18 | 2. Link to the latest web interface
19 |
20 | ## Release container
21 |
22 | 1. Run publish container action
23 | 2. Update `latest` tag
24 | 1. `docker tag ghcr.io/confusedpolarbear/jellyfin-intro-skipper:{COMMIT,latest}`
25 | 2. `docker push ghcr.io/confusedpolarbear/jellyfin-intro-skipper:latest`
26 |
--------------------------------------------------------------------------------
/docs/web_interface.md:
--------------------------------------------------------------------------------
1 | # Using the modified web interface
2 |
3 | If you run Jellyfin as a container, there are two different ways to use the modified web interface:
4 | 1. Mounting the new web interface to your existing Jellyfin container, or
5 | 2. Switching container images
6 |
7 | If you do not run Jellyfin as a container, you can follow the [native installation](native.md) instructions.
8 |
9 | ## Method 1: mounting the new web interface
10 |
11 | 1. Download and extract the latest modified web interface from [GitHub actions](https://github.com/ConfusedPolarBear/intro-skipper/actions/workflows/container.yml)
12 | 1. Click the most recent action run
13 | 2. In the Artifacts section, click the `jellyfin-web-VERSION+COMMIT.tar.gz` link to download a pre-compiled copy of the web interface. This link will only work if you are signed into GitHub.
14 | 2. Extract the archive somewhere on your server and make note of the full path to the `dist` folder
15 | 3. Mount the `dist` folder to your container as `/jellyfin/jellyfin-web` if using the official container, or `/usr/share/jellyfin/web` if using the linuxserver container. Example docker-compose snippet:
16 | ```yaml
17 | services:
18 | jellyfin:
19 | ports:
20 | - '8096:8096'
21 | volumes:
22 | # change `:ro` to `:rw` if you are using a plugin that modifies Jellyfin's web interface from inside the container (such as Jellyscrub)
23 | - '/full/path/to/extracted/dist:/jellyfin/jellyfin-web:ro' # <== add this line if using the official container
24 | - '/full/path/to/extracted/dist:/usr/share/jellyfin/web:ro' # <== add this line if using the linuxserver container
25 | - '/config:/config'
26 | - '/media:/media:ro'
27 | image: 'jellyfin/jellyfin:10.8.0'
28 | ```
29 |
30 | Make sure to clear the browser's cached version of the web interface before testing the skip button.
31 |
32 | ### Instructions for Unraid users only
33 |
34 | In the Docker tab, click on the Jellyfin container, then click on "Edit" and enable the advanced view. Under "Extra Parameters", add one of the following:
35 |
36 | * If using the `jellyfin/jellyfin` container: `--volume /full/path/to/extracted/dist:/jellyfin/jellyfin-web:ro`
37 | * If using the `linuxserver/jellyfin` container: `--volume /full/path/to/extracted/dist:/usr/share/jellyfin/web:ro`
38 |
39 | ## Method 2: switching container images
40 |
41 | 1. Run the `ghcr.io/confusedpolarbear/jellyfin-intro-skipper` container just as you would any other Jellyfin container
42 | 1. If you reuse the configuration data from another container, **make sure to create a backup first**.
43 |
44 | The Dockerfile which builds this container can be viewed [here](../docker/Dockerfile).
45 |
--------------------------------------------------------------------------------
/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/686c978a2f5f57a909dc6e76029a0c225e2e3ec4/images/logo.png
--------------------------------------------------------------------------------
/images/skip-button.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/686c978a2f5f57a909dc6e76029a0c225e2e3ec4/images/skip-button.png
--------------------------------------------------------------------------------
/jellyfin.ruleset:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "guid": "c83d86bb-a1e0-4c35-a113-e2101cf4ee6b",
4 | "name": "Intro Skipper",
5 | "overview": "Automatically detect and skip intros in television episodes",
6 | "description": "Analyzes the audio of television episodes and detects introduction sequences.",
7 | "owner": "ConfusedPolarBear",
8 | "category": "General",
9 | "imageUrl": "https://raw.githubusercontent.com/ConfusedPolarBear/intro-skipper/master/images/logo.png",
10 | "versions": [
11 | {
12 | "version": "0.1.7.0",
13 | "changelog": "- See the full changelog at [GitHub](https://github.com/ConfusedPolarBear/intro-skipper/blob/master/CHANGELOG.md)\n",
14 | "targetAbi": "10.8.4.0",
15 | "sourceUrl": "https://github.com/ConfusedPolarBear/intro-skipper/releases/download/v0.1.7/intro-skipper-v0.1.7.zip",
16 | "checksum": "76676ea60b270879c831b50f6cb21ae1",
17 | "timestamp": "2022-10-27T03:27:27Z"
18 | },
19 | {
20 | "version": "0.1.6.0",
21 | "changelog": "- See the full changelog at [GitHub](https://github.com/ConfusedPolarBear/intro-skipper/blob/master/CHANGELOG.md)\n",
22 | "targetAbi": "10.8.1.0",
23 | "sourceUrl": "https://github.com/ConfusedPolarBear/intro-skipper/releases/download/v0.1.6/intro-skipper-v0.1.6.zip",
24 | "checksum": "625990fa5f92244c5269143da42697ea",
25 | "timestamp": "2022-08-05T06:04:59Z"
26 | },
27 | {
28 | "version": "0.1.5.0",
29 | "changelog": "- use ffmpeg instead of fpcalc\n- version API endpoints\n- see CHANGELOG.md for full list of changes",
30 | "targetAbi": "10.8.0.0",
31 | "sourceUrl": "https://github.com/ConfusedPolarBear/intro-skipper/releases/download/v0.1.5.0/intro-skipper-v0.1.5.zip",
32 | "checksum": "cdb5baa20a37af1559d85a4aeb4760fa",
33 | "timestamp": "2022-06-18T03:01:21Z"
34 | },
35 | {
36 | "version": "0.1.0.0",
37 | "changelog": "- add automatic intro skip\n- cache audio fingerprints by default\n- switch to new fingerprint comparison algorithm",
38 | "targetAbi": "10.8.0.0",
39 | "sourceUrl": "https://github.com/ConfusedPolarBear/intro-skipper/releases/download/v0.1.0/intro-skipper-v0.1.0.zip",
40 | "checksum": "7f1fe0e60bf8e78723fb5b10b1b137cb",
41 | "timestamp": "2022-06-09T20:21:57Z"
42 | },
43 | {
44 | "version": "0.0.0.3",
45 | "changelog": "- fix version check",
46 | "targetAbi": "10.8.0.0",
47 | "sourceUrl": "https://github.com/ConfusedPolarBear/intro-skipper/releases/download/v0.0.3/intro-skipper-v0.0.3.zip",
48 | "checksum": "4d9eee379679c13c351d64f9d8c52a23",
49 | "timestamp": "2022-05-22T03:43:29Z"
50 | },
51 | {
52 | "version": "0.0.0.2",
53 | "changelog": "- decrease audio fingerprint comparison time\n- analyze two seasons simultaneously",
54 | "targetAbi": "10.8.0.0",
55 | "sourceUrl": "https://github.com/ConfusedPolarBear/intro-skipper/releases/download/v0.0.2/intro-skipper-v0.0.2.zip",
56 | "checksum": "83c3e3618a6c37c2767e5bfea64ef515",
57 | "timestamp": "2022-05-22T00:05:12Z"
58 | },
59 | {
60 | "version": "0.0.0.1",
61 | "changelog": "- initial alpha release",
62 | "targetAbi": "10.8.0.0",
63 | "sourceUrl": "https://github.com/ConfusedPolarBear/intro-skipper/releases/download/v0.0.1/intro-skipper-v0.0.1.zip",
64 | "checksum": "4b0e4ae45d09ecd9014a4986f3095ce4",
65 | "timestamp": "2022-05-10T07:57:11Z"
66 | }
67 | ]
68 | }
69 | ]
70 |
--------------------------------------------------------------------------------