├── .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 | Plugin Banner 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 | ![Skip intro button](images/skip-button.png) 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 | --------------------------------------------------------------------------------