diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index b84e563..0000000 --- a/.editorconfig +++ /dev/null @@ -1,194 +0,0 @@ -# With more recent updates Visual Studio 2017 supports EditorConfig files out of the box -# Visual Studio Code needs an extension: https://github.com/editorconfig/editorconfig-vscode -# For emacs, vim, np++ and other editors, see here: https://github.com/editorconfig -############################### -# Core EditorConfig Options # -############################### -root = true -# All files -[*] -indent_style = space -indent_size = 4 -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true -end_of_line = lf -max_line_length = off - -# YAML indentation -[*.{yml,yaml}] -indent_size = 2 - -# XML indentation -[*.{csproj,xml}] -indent_size = 2 - -############################### -# .NET Coding Conventions # -############################### -[*.{cs,vb}] -# Organize usings -dotnet_sort_system_directives_first = true -# this. preferences -dotnet_style_qualification_for_field = false:silent -dotnet_style_qualification_for_property = false:silent -dotnet_style_qualification_for_method = false:silent -dotnet_style_qualification_for_event = false:silent -# Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true:silent -dotnet_style_predefined_type_for_member_access = true:silent -# Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent -# Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent -dotnet_style_readonly_field = true:suggestion -# Expression-level preferences -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion -dotnet_style_prefer_auto_properties = true:silent -dotnet_style_prefer_conditional_expression_over_assignment = true:silent -dotnet_style_prefer_conditional_expression_over_return = true:silent - -############################### -# Naming Conventions # -############################### -# Style Definitions (From Roslyn) - -# Non-private static fields are PascalCase -dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields -dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style - -dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field -dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected -dotnet_naming_symbols.non_private_static_fields.required_modifiers = static - -dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case - -# Constants are PascalCase -dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants -dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style - -dotnet_naming_symbols.constants.applicable_kinds = field, local -dotnet_naming_symbols.constants.required_modifiers = const - -dotnet_naming_style.constant_style.capitalization = pascal_case - -# Static fields are camelCase and start with s_ -dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion -dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields -dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style - -dotnet_naming_symbols.static_fields.applicable_kinds = field -dotnet_naming_symbols.static_fields.required_modifiers = static - -dotnet_naming_style.static_field_style.capitalization = camel_case -dotnet_naming_style.static_field_style.required_prefix = _ - -# Instance fields are camelCase and start with _ -dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion -dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields -dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style - -dotnet_naming_symbols.instance_fields.applicable_kinds = field - -dotnet_naming_style.instance_field_style.capitalization = camel_case -dotnet_naming_style.instance_field_style.required_prefix = _ - -# Locals and parameters are camelCase -dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion -dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters -dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style - -dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local - -dotnet_naming_style.camel_case_style.capitalization = camel_case - -# Local functions are PascalCase -dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions -dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style - -dotnet_naming_symbols.local_functions.applicable_kinds = local_function - -dotnet_naming_style.local_function_style.capitalization = pascal_case - -# By default, name items with PascalCase -dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members -dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style - -dotnet_naming_symbols.all_members.applicable_kinds = * - -dotnet_naming_style.pascal_case_style.capitalization = pascal_case - -############################### -# C# Coding Conventions # -############################### -[*.cs] -# var preferences -csharp_style_var_for_built_in_types = true:silent -csharp_style_var_when_type_is_apparent = true:silent -csharp_style_var_elsewhere = true:silent -# Expression-bodied members -csharp_style_expression_bodied_methods = false:silent -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_accessors = true:silent -# Pattern matching preferences -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion -# Null-checking preferences -csharp_style_throw_expression = true:suggestion -csharp_style_conditional_delegate_call = true:suggestion -# Modifier preferences -csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion -# Expression-level preferences -csharp_prefer_braces = true:silent -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_prefer_simple_default_expression = true:suggestion -csharp_style_pattern_local_over_anonymous_function = true:suggestion -csharp_style_inlined_variable_declaration = true:suggestion - -############################### -# C# Formatting Rules # -############################### -# New line preferences -csharp_new_line_before_open_brace = all -csharp_new_line_before_else = true -csharp_new_line_before_catch = true -csharp_new_line_before_finally = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_between_query_expression_clauses = true -# Indentation preferences -csharp_indent_case_contents = true -csharp_indent_switch_labels = true -csharp_indent_labels = flush_left -# Space preferences -csharp_space_after_cast = false -csharp_space_after_keywords_in_control_flow_statements = true -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_after_colon_in_inheritance_clause = true -csharp_space_around_binary_operators = before_and_after -csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false -# Wrapping preferences -csharp_preserve_single_line_statements = true -csharp_preserve_single_line_blocks = true diff --git a/.github/ISSUE_TEMPLATE/bug_report_form.yml b/.github/ISSUE_TEMPLATE/bug_report_form.yml deleted file mode 100644 index 9673c81..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report_form.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: "Bug report" -description: "Create a report to help us improve" -title: "[Bug]: " -labels: [bug] -body: - - type: checkboxes - id: requirements - attributes: - label: Self service debugging - description: | - Jellyfin 10.9 is still being actively updated. Please make sure you are using the newest release - - Docker containers have known permission issues that can be resolved with a few extra steps. - If your skip button is not shown, please see https://github.com/jumoog/intro-skipper/issues/104 - options: - - label: Jellyfin is updated and my permissions are correct (or I did not use Docker) - required: true - - type: textarea - attributes: - label: Describe the bug - description: Also tell us, what did you expect to happen? - placeholder: | - The more information that you are able to provide, the better. Did you do anything before this happened? Did you upgrade or change anything? Any screenshots or logs you can provide will be helpful. - - This is my issue. - - Steps to Reproduce - 1. In this environment... - 2. With this config... - 3. Run '...' - 4. See error... - validations: - required: true - - - type: input - attributes: - label: Jellyfin install method - description: How you installed Jellyfin or the tool used to install it - placeholder: Docker, Windows installer, etc. - validations: - required: true - - - type: input - attributes: - label: Container image/tag or Jellyfin version - description: The container for Docker or Jellyfin version for a native install - placeholder: jellyfin/jellyfin:10.8.7, jellyfin-intro-skipper:latest, etc. - validations: - required: true - - - type: input - attributes: - label: Operating System - description: The operating system of the Jellyfin / Docker host computer - placeholder: Debian 11, Windows 11, etc. - validations: - required: true - - - type: input - attributes: - label: IMDb ID of that TV Series - placeholder: tt0903747 - - - type: textarea - attributes: - label: Support Bundle - placeholder: go to Dashboard -> Plugins -> Intro Skipper -> Support Bundle (at the bottom of the page) and paste the contents of the textbox here - render: shell - validations: - required: true - - - type: textarea - attributes: - label: Jellyfin logs - placeholder: Paste any relevant logs here - render: shell - diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 325dec4..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,29 +0,0 @@ -version: 2 -updates: - # Fetch and update latest `nuget` pkgs - - package-ecosystem: nuget - directory: / - schedule: - interval: weekly - open-pull-requests-limit: 10 - labels: - - chore - - dependency - - nuget - commit-message: - prefix: chore - include: scope - - # Fetch and update latest `github-actions` pkgs - - package-ecosystem: github-actions - directory: / - schedule: - interval: monthly - open-pull-requests-limit: 10 - labels: - - ci - - dependency - - github_actions - commit-message: - prefix: ci - include: scope diff --git a/.github/workflows/BuildImage.yml b/.github/workflows/BuildImage.yml new file mode 100644 index 0000000..b65f5ff --- /dev/null +++ b/.github/workflows/BuildImage.yml @@ -0,0 +1,61 @@ +name: Build Image + +on: [push, pull_request, workflow_dispatch] + +env: + ENDPOINT: "jumoog/intro-skipper" + BRANCH: "docker-mod" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.3 + + - name: Build image + run: | + docker build --no-cache -t ${{ github.sha }} . + + - name: Tag image + if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.ENDPOINT != 'user/endpoint' }} + run: | + docker tag ${{ github.sha }} ${ENDPOINT} + docker tag ${{ github.sha }} ${ENDPOINT}:${{ github.sha }} + docker tag ${{ github.sha }} ghcr.io/${ENDPOINT} + docker tag ${{ github.sha }} ghcr.io/${ENDPOINT}:${{ github.sha }} + + - name: Credential check + if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.ENDPOINT != 'user/endpoint' }} + run: | + echo "CR_USER=${{ secrets.CR_USER }}" >> $GITHUB_ENV + echo "CR_PAT=${{ secrets.CR_PAT }}" >> $GITHUB_ENV + echo "DOCKERUSER=${{ secrets.DOCKERUSER }}" >> $GITHUB_ENV + echo "DOCKERPASS=${{ secrets.DOCKERPASS }}" >> $GITHUB_ENV + if [[ "${{ secrets.CR_USER }}" == "" && "${{ secrets.CR_PAT }}" == "" && "${{ secrets.DOCKERUSER }}" == "" && "${{ secrets.DOCKERPASS }}" == "" ]]; then + echo "::error::Push credential secrets missing." + echo "::error::You must set either CR_USER & CR_PAT or DOCKERUSER & DOCKERPASS as secrets in your repo settings." + echo "::error::See https://github.com/linuxserver/docker-mods/blob/master/README.md for more information/instructions." + exit 1 + fi + + - name: Login to GitHub Container Registry + if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.CR_USER && env.CR_PAT && env.ENDPOINT != 'user/endpoint' }} + run: | + echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ secrets.CR_USER }} --password-stdin + + - name: Push tags to GitHub Container Registry + if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.CR_USER && env.CR_PAT && env.ENDPOINT != 'user/endpoint' }} + run: | + docker push ghcr.io/${ENDPOINT}:${{ github.sha }} + docker push ghcr.io/${ENDPOINT} + + - name: Login to DockerHub + if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.DOCKERUSER && env.DOCKERPASS && env.ENDPOINT != 'user/endpoint' }} + run: | + echo ${{ secrets.DOCKERPASS }} | docker login -u ${{ secrets.DOCKERUSER }} --password-stdin + + - name: Push tags to DockerHub + if: ${{ github.ref == format('refs/heads/{0}', env.BRANCH) && env.DOCKERUSER && env.DOCKERPASS && env.ENDPOINT != 'user/endpoint' }} + run: | + docker push ${ENDPOINT}:${{ github.sha }} + docker push ${ENDPOINT} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 0e50dcd..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: 'Build Plugin' - -on: - push: - branches: [ "master" ] - paths-ignore: - - '**/README.md' - - '.github/ISSUE_TEMPLATE/**' - - 'docs/**' - - 'images/**' - - 'manifest.json' - pull_request: - branches: [ "master" ] - paths-ignore: - - '**/README.md' - - '.github/ISSUE_TEMPLATE/**' - - 'docs/**' - - 'images/**' - - 'manifest.json' - -permissions: - contents: write - packages: write - -jobs: - build: - if: ${{ ! startsWith(github.event.head_commit.message, 'v0.') }} - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Embed version info - run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt - - - name: Retrieve commit identification - run: | - GIT_HASH=$(git rev-parse --short HEAD) - echo "GIT_HASH=${GIT_HASH}" >> $GITHUB_ENV - - - name: Build - run: dotnet build --no-restore - - - name: Upload artifact - uses: actions/upload-artifact@v4.3.3 - if: github.event_name != 'pull_request' - with: - name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.GIT_HASH }}.dll - path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll - if-no-files-found: error - - - name: Upload artifact - uses: actions/upload-artifact@v4.3.3 - if: github.event_name == 'pull_request' - with: - name: ConfusedPolarBear.Plugin.IntroSkipper-${{ github.head_ref }}.dll - path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll - retention-days: 7 - if-no-files-found: error - - - name: Create archive - if: github.event_name != 'pull_request' - run: zip -j "intro-skipper-${{ env.GIT_HASH }}.zip" ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll - - - name: Generate md5 - if: github.event_name != 'pull_request' - run: | - md5sum intro-skipper-${{ env.GIT_HASH }}.zip > intro-skipper-${{ env.GIT_HASH }}.md5 - checksum="$(awk '{print $1}' intro-skipper-${{ env.GIT_HASH }}.md5)" - echo "CHECKSUM=$checksum" >> $GITHUB_ENV - - - name: Create/replace the preview release and upload artifacts - if: github.event_name != 'pull_request' - run: | - gh release delete '10.9/preview' --cleanup-tag --yes || true - gh release create '10.9/preview' "intro-skipper-${{ env.GIT_HASH }}.zip" --prerelease --title "intro-skipper-${{ env.GIT_HASH }}" --notes "This is a prerelease version." - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/call_issue_pr_tracker.yml b/.github/workflows/call_issue_pr_tracker.yml new file mode 100644 index 0000000..2c30784 --- /dev/null +++ b/.github/workflows/call_issue_pr_tracker.yml @@ -0,0 +1,16 @@ +name: Issue & PR Tracker + +on: + issues: + types: [opened,reopened,labeled,unlabeled,closed] + pull_request_target: + types: [opened,reopened,review_requested,review_request_removed,labeled,unlabeled,closed] + pull_request_review: + types: [submitted,edited,dismissed] + +jobs: + manage-project: + permissions: + issues: write + uses: linuxserver/github-workflows/.github/workflows/issue-pr-tracker.yml@v1 + secrets: inherit diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 7b4d6cd..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: "CodeQL" - -on: - push: - branches: [ master ] - paths-ignore: - - '**/README.md' - - '.github/ISSUE_TEMPLATE/**' - - 'docs/**' - - 'images/**' - - 'manifest.json' - pull_request: - branches: [ master ] - paths-ignore: - - '**/README.md' - - '.github/ISSUE_TEMPLATE/**' - - 'docs/**' - - 'images/**' - - 'manifest.json' - -permissions: write-all - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - language: [ 'csharp' ] - - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Install dependencies - run: | - dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json" - dotnet tool install --global dotnet-outdated-tool - dotnet outdated -pre Always -u -inc Jellyfin - - - name: Initialize CodeQL - uses: github/codeql-action/init@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8 - with: - languages: ${{ matrix.language }} - queries: +security-extended - - name: Autobuild - uses: github/codeql-action/autobuild@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8 diff --git a/.github/workflows/permissions.yml b/.github/workflows/permissions.yml new file mode 100644 index 0000000..1447bc5 --- /dev/null +++ b/.github/workflows/permissions.yml @@ -0,0 +1,10 @@ +name: Permission check +on: + pull_request_target: + paths: + - '**/run' + - '**/finish' + - '**/check' +jobs: + permission_check: + uses: linuxserver/github-workflows/.github/workflows/init-svc-executable-permissions.yml@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1029e23..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: 'Release Plugin' - -on: - workflow_dispatch: - -permissions: - contents: write - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Run update version - run: node update-version.js - - - name: Embed version info - run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt - - - name: Build - run: dotnet build --no-restore - - - name: Upload artifact - uses: actions/upload-artifact@v4.3.3 - with: - name: ConfusedPolarBear.Plugin.IntroSkipper-v${{ env.NEW_FILE_VERSION }}.dll - path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll - if-no-files-found: error - - - name: Create archive - run: zip -j "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net8.0/ConfusedPolarBear.Plugin.IntroSkipper.dll - - - name: Generate manifest keys - run: | - sourceUrl="https://github.com/${{ github.repository }}/releases/download/10.9/v${{ env.NEW_FILE_VERSION }}/intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" - echo "SOURCE_URL=$sourceUrl" >> $GITHUB_ENV - md5sum intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip > intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5 - checksum="$(awk '{print $1}' intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5)" - echo "CHECKSUM=$checksum" >> $GITHUB_ENV - timestamp="$(date +%FT%TZ)" - echo "TIMESTAMP=$timestamp" >> $GITHUB_ENV - - - name: Create new release with tag - if: github.event_name != 'pull_request' - run: gh release create "10.9/v${{ env.NEW_FILE_VERSION }}" "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" --title "v${{ env.NEW_FILE_VERSION }}" --latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Run validation and update script - run: node validate-and-update-manifest.js - env: - VERSION: ${{ env.NEW_FILE_VERSION }} - - - name: Commit changes - if: success() - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git add manifest.json ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj - git commit -m "release v${{ env.NEW_FILE_VERSION }}" - git push diff --git a/.gitignore b/.gitignore deleted file mode 100644 index bc18978..0000000 --- a/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -bin/ -obj/ -BenchmarkDotNet.Artifacts/ -/package/ - -# Ignore pre compiled web interface -docker/dist - -# Visual Studio -.vs/ diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md deleted file mode 100644 index 879b460..0000000 --- a/ACKNOWLEDGEMENTS.md +++ /dev/null @@ -1,7 +0,0 @@ -Intro Skipper is made possible by the following open source projects: - -* [acoustid-match](https://github.com/dnknth/acoustid-match) (MIT) -* [chromaprint](https://github.com/acoustid/chromaprint) (LGPL 2.1) -* [JellyScrub](https://github.com/nicknsy/jellyscrub) (MIT) -* [Jellyfin](https://github.com/jellyfin/jellyfin) (GPL) -* [Jellyfin Media Analyzer](https://github.com/endrl/jellyfin-plugin-media-analyzer) (GPL) diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index b12acea..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,93 +0,0 @@ -# Changelog - -## v0.1.8.0 (no eta) -* New features - * Support adding skip intro button to web interface without using a fork - * Add localization support for the skip intro button and the automatic skip notification message - * Detect ending credits in television episodes - * Add support for using chapter names to locate introductions and ending credits - * Add support for using black frames to locate ending credits - * Show skip button when on screen controls are visible (#149 by @DualScorch) -* Internal changes - * Move Chromaprint analysis code out of the episode analysis task - * Add support for multiple analysis techinques - -## v0.1.7.0 (2022-10-26) -* New features - * Rewrote fingerprint comparison algorithm to be faster (~30x speedup) and detect more introductions - * Detect silence at the end of introductions and use it to avoid skipping over the beginning of an episode - * If you are upgrading from a previous release and want to use the silence detection feature on shows that have already been analyzed, you must click the `Erase introduction timestamps` button at the bottom of the plugin settings page - * Add support bundle - * Add maximum introduction duration - * Support playing a few seconds from the end of the introduction to verify that no episode content was skipped over - * Amount played is customizable and defaults to 2 seconds - * Support modifying introduction detection algorithm settings - * Add option to not skip the introduction in the first episode of a season - * Add option to analyze show extras (specials) -* Fixes - * Fix scheduled task interval (#79) - * Prevent show names from becoming duplicated in the show name dropdown under the advanced section - * Prevent virtual episodes from being inserted into the analysis queue - -## v0.1.6.0 (2022-08-04) -* New features - * Generate EDL files with intro timestamps ([documentation](docs/edl.md)) (#21) - * Support selecting which libraries are analyzed (#37) - * Support customizing [introduction requirements](README.md#introduction-requirements) (#38, #51) - * Changing these settings will increase episode analysis times - * Support adding and editing intro timestamps (#26) - * Report how CPU time is being spent while analyzing episodes - * CPU time reports can be viewed under "Analysis Statistics (experimental)" in the plugin configuration page - * Sped up fingerprint analysis (not including fingerprint generation time) by 40% - * Support erasing discovered introductions by season - * Suggest potential shifts in the fingerprint visualizer - -* Fixes - * Ensure episode analysis queue matches the current filesystem and library state (#42, #60) - * Fixes a bug where renamed or deleted episodes were being analyzed - * Fix automatic intro skipping on Android TV (#57, #61) - * Restore per season status updates in the log - * Prevent null key in `/Intros/Shows` endpoint (#27) - * Fix positioning of skip intro button on mobile devices (#43) - * Ensure video playback always resumes after clicking the skip intro button (#44) - -## v0.1.5.0 (2022-06-17) -* Use `ffmpeg` to generate audio fingerprints instead of `fpcalc` - * Requires that the installed version of `ffmpeg`: - * Was compiled with the `--enable-chromaprint` option - * Understands the `-fp_format raw` flag - * `jellyfin-ffmpeg 5.0.1-5` meets both of these requirements -* Version API endpoints - * See [api.md](docs/api.md) for detailed documentation on how clients can work with this plugin -* Add commit hash to unstable builds -* Log media paths that are unable to be fingerprinted -* Report failure to the UI if the episode analysis queue is empty -* Allow customizing degrees of parallelism - * Warning: Using a value that is too high will result in system instability -* Remove restart requirement to change auto skip setting -* Rewrite startup enqueue -* Fix deadlock issue on Windows (#23 by @nyanmisaka) -* Improve skip intro button styling & positioning (ConfusedPolarBear/jellyfin-web#91 by @Fallenbagel) -* Order episodes by `IndexNumber` (#25 reported by @Flo56958) - - -## v0.1.0.0 (2022-06-09) -* Add option to automatically skip intros -* Cache audio fingerprints by default -* Add fingerprint visualizer -* Add button to erase all previously discovered intro timestamps -* Made saving settings more reliable -* Switch to new fingerprint comparison algorithm - * If you would like to test the new comparison algorithm, you will have to erase all previously discovered introduction timestamps. - -## v0.0.0.3 (2022-05-21) -* Fix `fpcalc` version check - -## v0.0.0.2 (2022-05-21) -* Analyze multiple seasons in parallel -* Reanalyze episodes with an unusually short or long intro sequence -* Check installed `fpcalc` version -* Clarify installation instructions - -## v0.0.0.1 (2022-05-10) -* First alpha build diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/ConfusedPolarBear.Plugin.IntroSkipper.Tests.csproj b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/ConfusedPolarBear.Plugin.IntroSkipper.Tests.csproj deleted file mode 100644 index 5316211..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/ConfusedPolarBear.Plugin.IntroSkipper.Tests.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net8.0 - enable - - false - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs deleted file mode 100644 index b07629a..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs +++ /dev/null @@ -1,162 +0,0 @@ -/* These tests require that the host system has a version of FFmpeg installed - * which supports both chromaprint and the "-fp_format raw" flag. - */ - -using System; -using System.Collections.Generic; -using Xunit; -using Microsoft.Extensions.Logging; - -namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; - -public class TestAudioFingerprinting -{ - [FactSkipFFmpegTests] - public void TestInstallationCheck() - { - Assert.True(FFmpegWrapper.CheckFFmpegVersion()); - } - - [Theory] - [InlineData(0, 0)] - [InlineData(1, 1)] - [InlineData(5, 213)] - [InlineData(10, 56_021)] - [InlineData(16, 16_112_341)] - [InlineData(19, 2_465_585_877)] - public void TestBitCounting(int expectedBits, uint number) - { - var chromaprint = CreateChromaprintAnalyzer(); - Assert.Equal(expectedBits, chromaprint.CountBits(number)); - } - - [FactSkipFFmpegTests] - public void TestFingerprinting() - { - // Generated with `fpcalc -raw audio/big_buck_bunny_intro.mp3` - var expected = new uint[]{ - 3269995649, 3261610160, 3257403872, 1109989680, 1109993760, 1110010656, 1110142768, 1110175504, - 1110109952, 1126874880, 2788611, 2787586, 6981634, 15304754, 28891170, 43579426, 43542561, - 47737888, 41608640, 40559296, 36352644, 53117572, 2851460, 1076465548, 1080662428, 1080662492, - 1089182044, 1148041501, 1148037422, 3291343918, 3290980398, 3429367854, 3437756714, 3433698090, - 3433706282, 3366600490, 3366464314, 2296916250, 3362269210, 3362265115, 3362266441, 3370784472, - 3366605480, 1218990776, 1223217816, 1231602328, 1260950200, 1245491640, 169845176, 1510908120, - 1510911000, 2114365528, 2114370008, 1996929688, 1996921480, 1897171592, 1884588680, 1347470984, - 1343427226, 1345467054, 1349657318, 1348673570, 1356869666, 1356865570, 295837698, 60957698, - 44194818, 48416770, 40011778, 36944210, 303147954, 369146786, 1463847842, 1434488738, 1417709474, - 1417713570, 3699441634, 3712167202, 3741460534, 2585144342, 2597725238, 2596200487, 2595926077, - 2595984141, 2594734600, 2594736648, 2598931176, 2586348264, 2586348264, 2586561257, 2586451659, - 2603225802, 2603225930, 2573860970, 2561151018, 3634901034, 3634896954, 3651674122, 3416793162, - 3416816715, 3404331257, 3395844345, 3395836155, 3408464089, 3374975369, 1282036360, 1290457736, - 1290400440, 1290314408, 1281925800, 1277727404, 1277792932, 1278785460, 1561962388, 1426698196, - 3607924711, 4131892839, 4140215815, 4292259591, 3218515717, 3209938229, 3171964197, 3171956013, - 4229117295, 4229312879, 4242407935, 4240114111, 4239987133, 4239990013, 3703060732, 1547188252, - 1278748677, 1278748935, 1144662786, 1148854786, 1090388802, 1090388962, 1086260130, 1085940098, - 1102709122, 45811586, 44634002, 44596656, 44592544, 1122527648, 1109944736, 1109977504, 1111030243, - 1111017762, 1109969186, 1126721826, 1101556002, 1084844322, 1084979506, 1084914450, 1084914449, - 1084873520, 3228093296, 3224996817, 3225062275, 3241840002, 3346701698, 3349843394, 3349782306, - 3349719842, 3353914146, 3328748322, 3328747810, 3328809266, 3471476754, 3472530451, 3472473123, - 3472417825, 3395841056, 3458735136, 3341420624, 1076496560, 1076501168, 1076501136, 1076497024 - }; - - var actual = FFmpegWrapper.Fingerprint( - queueEpisode("audio/big_buck_bunny_intro.mp3"), - AnalysisMode.Introduction); - - Assert.Equal(expected, actual); - } - - [Fact] - public void TestIndexGeneration() - { - // 0 1 2 3 4 5 6 7 - var fpr = new uint[] { 1, 2, 3, 1, 5, 77, 42, 2 }; - var expected = new Dictionary() - { - {1, 3}, - {2, 7}, - {3, 2}, - {5, 4}, - {42, 6}, - {77, 5}, - }; - - var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction); - - Assert.Equal(expected, actual); - } - - [FactSkipFFmpegTests] - public void TestIntroDetection() - { - var chromaprint = CreateChromaprintAnalyzer(); - - var lhsEpisode = queueEpisode("audio/big_buck_bunny_intro.mp3"); - var rhsEpisode = queueEpisode("audio/big_buck_bunny_clip.mp3"); - var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction); - var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction); - - var (lhs, rhs) = chromaprint.CompareEpisodes( - lhsEpisode.EpisodeId, - lhsFingerprint, - rhsEpisode.EpisodeId, - rhsFingerprint); - - Assert.True(lhs.Valid); - Assert.Equal(0, lhs.IntroStart); - Assert.Equal(17.208, lhs.IntroEnd, 3); - - Assert.True(rhs.Valid); - // because we changed for 0.128 to 0.1238 its 4,952 now but that's too early (<= 5) - Assert.Equal(0, rhs.IntroStart); - Assert.Equal(22.1602, rhs.IntroEnd); - } - - /// - /// Test that the silencedetect wrapper is working. - /// - [FactSkipFFmpegTests] - public void TestSilenceDetection() - { - var clip = queueEpisode("audio/big_buck_bunny_clip.mp3"); - - var expected = new TimeRange[] - { - new TimeRange(44.6310, 44.8072), - new TimeRange(53.5905, 53.8070), - new TimeRange(53.8458, 54.2024), - new TimeRange(54.2611, 54.5935), - new TimeRange(54.7098, 54.9293), - new TimeRange(54.9294, 55.2590), - }; - - var actual = FFmpegWrapper.DetectSilence(clip, 60); - - Assert.Equal(expected, actual); - } - - private QueuedEpisode queueEpisode(string path) - { - return new QueuedEpisode() - { - EpisodeId = Guid.NewGuid(), - Path = "../../../" + path, - IntroFingerprintEnd = 60 - }; - } - - private ChromaprintAnalyzer CreateChromaprintAnalyzer() - { - var logger = new LoggerFactory().CreateLogger(); - return new(logger); - } -} - -public class FactSkipFFmpegTests : FactAttribute -{ -#if SKIP_FFMPEG_TESTS - public FactSkipFFmpegTests() { - Skip = "SKIP_FFMPEG_TESTS defined, skipping unit tests that require FFmpeg to be installed"; - } -#endif -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs deleted file mode 100644 index 60d3115..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs +++ /dev/null @@ -1,73 +0,0 @@ -namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; - -using System; -using System.Collections.Generic; -using Microsoft.Extensions.Logging; -using Xunit; - -public class TestBlackFrames -{ - [FactSkipFFmpegTests] - public void TestBlackFrameDetection() - { - var range = 1e-5; - - var expected = new List(); - expected.AddRange(CreateFrameSequence(2.04, 3)); - expected.AddRange(CreateFrameSequence(5, 6)); - expected.AddRange(CreateFrameSequence(8, 9.96)); - - var actual = FFmpegWrapper.DetectBlackFrames(queueFile("rainbow.mp4"), new(0, 10), 85); - - for (var i = 0; i < expected.Count; i++) - { - var (e, a) = (expected[i], actual[i]); - Assert.Equal(e.Percentage, a.Percentage); - Assert.InRange(a.Time, e.Time - range, e.Time + range); - } - } - - [FactSkipFFmpegTests] - public void TestEndCreditDetection() - { - // new strategy new range - var range = 3; - - var analyzer = CreateBlackFrameAnalyzer(); - - var episode = queueFile("credits.mp4"); - episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds; - - var result = analyzer.AnalyzeMediaFile(episode, 240, 30, 85); - Assert.NotNull(result); - Assert.InRange(result.IntroStart, 300 - range, 300 + range); - } - - private QueuedEpisode queueFile(string path) - { - return new() - { - EpisodeId = Guid.NewGuid(), - Name = path, - Path = "../../../video/" + path - }; - } - - private BlackFrame[] CreateFrameSequence(double start, double end) - { - var frames = new List(); - - for (var i = start; i < end; i += 0.04) - { - frames.Add(new(100, i)); - } - - return frames.ToArray(); - } - - private BlackFrameAnalyzer CreateBlackFrameAnalyzer() - { - var logger = new LoggerFactory().CreateLogger(); - return new(logger); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs deleted file mode 100644 index 96feb8e..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs +++ /dev/null @@ -1,83 +0,0 @@ -namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; - -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using MediaBrowser.Model.Entities; -using Microsoft.Extensions.Logging; -using Xunit; - -public class TestChapterAnalyzer -{ - [Theory] - [InlineData("Opening")] - [InlineData("OP")] - [InlineData("Intro")] - [InlineData("Intro Start")] - [InlineData("Introduction")] - public void TestIntroductionExpression(string chapterName) - { - var chapters = CreateChapters(chapterName, AnalysisMode.Introduction); - var introChapter = FindChapter(chapters, AnalysisMode.Introduction); - - Assert.NotNull(introChapter); - Assert.Equal(60, introChapter.IntroStart); - Assert.Equal(90, introChapter.IntroEnd); - } - - [Theory] - [InlineData("End Credits")] - [InlineData("Ending")] - [InlineData("Credit start")] - [InlineData("Closing Credits")] - [InlineData("Credits")] - public void TestEndCreditsExpression(string chapterName) - { - var chapters = CreateChapters(chapterName, AnalysisMode.Credits); - var creditsChapter = FindChapter(chapters, AnalysisMode.Credits); - - Assert.NotNull(creditsChapter); - Assert.Equal(1890, creditsChapter.IntroStart); - Assert.Equal(2000, creditsChapter.IntroEnd); - } - - private Intro? FindChapter(Collection chapters, AnalysisMode mode) - { - var logger = new LoggerFactory().CreateLogger(); - var analyzer = new ChapterAnalyzer(logger); - - var config = new Configuration.PluginConfiguration(); - var expression = mode == AnalysisMode.Introduction ? - config.ChapterAnalyzerIntroductionPattern : - config.ChapterAnalyzerEndCreditsPattern; - - return analyzer.FindMatchingChapter(new() { Duration = 2000 }, chapters, expression, mode); - } - - private Collection CreateChapters(string name, AnalysisMode mode) - { - var chapters = new[]{ - CreateChapter("Cold Open", 0), - CreateChapter(mode == AnalysisMode.Introduction ? name : "Introduction", 60), - CreateChapter("Main Episode", 90), - CreateChapter(mode == AnalysisMode.Credits ? name : "Credits", 1890) - }; - - return new(new List(chapters)); - } - - /// - /// Create a ChapterInfo object. - /// - /// Chapter name. - /// Chapter position (in seconds). - /// ChapterInfo. - private ChapterInfo CreateChapter(string name, int position) - { - return new() - { - Name = name, - StartPositionTicks = TimeSpan.FromSeconds(position).Ticks - }; - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestContiguous.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestContiguous.cs deleted file mode 100644 index 50121f6..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestContiguous.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Xunit; - -namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; - -public class TestTimeRanges -{ - [Fact] - public void TestSmallRange() - { - var times = new double[]{ - 1, 1.5, 2, 2.5, 3, 3.5, 4, - 100, 100.5, 101, 101.5 - }; - - var expected = new TimeRange(1, 4); - var actual = TimeRangeHelpers.FindContiguous(times, 2); - - Assert.Equal(expected, actual); - } - - [Fact] - public void TestLargeRange() - { - var times = new double[]{ - 1, 1.5, 2, - 2.8, 2.9, 2.995, 3.0, 3.01, 3.02, 3.4, 3.45, 3.48, 3.7, 3.77, 3.78, 3.781, 3.782, 3.789, 3.85, - 4.5, 5.3122, 5.3123, 5.3124, 5.3125, 5.3126, 5.3127, 5.3128, - 55, 55.5, 55.6, 55.7 - }; - - var expected = new TimeRange(1, 5.3128); - var actual = TimeRangeHelpers.FindContiguous(times, 2); - - Assert.Equal(expected, actual); - } - - [Fact] - public void TestFuturama() - { - // These timestamps were manually extracted from Futurama S01E04 and S01E05. - var times = new double[]{ - 2.176, 8.32, 10.112, 11.264, 13.696, 16, 16.128, 16.64, 16.768, 16.896, 17.024, 17.152, 17.28, - 17.408, 17.536, 17.664, 17.792, 17.92, 18.048, 18.176, 18.304, 18.432, 18.56, 18.688, 18.816, - 18.944, 19.072, 19.2, 19.328, 19.456, 19.584, 19.712, 19.84, 19.968, 20.096, 20.224, 20.352, - 20.48, 20.608, 20.736, 20.864, 20.992, 21.12, 21.248, 21.376, 21.504, 21.632, 21.76, 21.888, - 22.016, 22.144, 22.272, 22.4, 22.528, 22.656, 22.784, 22.912, 23.04, 23.168, 23.296, 23.424, - 23.552, 23.68, 23.808, 23.936, 24.064, 24.192, 24.32, 24.448, 24.576, 24.704, 24.832, 24.96, - 25.088, 25.216, 25.344, 25.472, 25.6, 25.728, 25.856, 25.984, 26.112, 26.24, 26.368, 26.496, - 26.624, 26.752, 26.88, 27.008, 27.136, 27.264, 27.392, 27.52, 27.648, 27.776, 27.904, 28.032, - 28.16, 28.288, 28.416, 28.544, 28.672, 28.8, 28.928, 29.056, 29.184, 29.312, 29.44, 29.568, - 29.696, 29.824, 29.952, 30.08, 30.208, 30.336, 30.464, 30.592, 30.72, 30.848, 30.976, 31.104, - 31.232, 31.36, 31.488, 31.616, 31.744, 31.872, 32, 32.128, 32.256, 32.384, 32.512, 32.64, - 32.768, 32.896, 33.024, 33.152, 33.28, 33.408, 33.536, 33.664, 33.792, 33.92, 34.048, 34.176, - 34.304, 34.432, 34.56, 34.688, 34.816, 34.944, 35.072, 35.2, 35.328, 35.456, 35.584, 35.712, - 35.84, 35.968, 36.096, 36.224, 36.352, 36.48, 36.608, 36.736, 36.864, 36.992, 37.12, 37.248, - 37.376, 37.504, 37.632, 37.76, 37.888, 38.016, 38.144, 38.272, 38.4, 38.528, 38.656, 38.784, - 38.912, 39.04, 39.168, 39.296, 39.424, 39.552, 39.68, 39.808, 39.936, 40.064, 40.192, 40.32, - 40.448, 40.576, 40.704, 40.832, 40.96, 41.088, 41.216, 41.344, 41.472, 41.6, 41.728, 41.856, - 41.984, 42.112, 42.24, 42.368, 42.496, 42.624, 42.752, 42.88, 43.008, 43.136, 43.264, 43.392, - 43.52, 43.648, 43.776, 43.904, 44.032, 44.16, 44.288, 44.416, 44.544, 44.672, 44.8, 44.928, - 45.056, 45.184, 57.344, 62.976, 68.864, 74.368, 81.92, 82.048, 86.528, 100.864, 102.656, - 102.784, 102.912, 103.808, 110.976, 116.864, 125.696, 128.384, 133.248, 133.376, 136.064, - 136.704, 142.976, 150.272, 152.064, 164.864, 164.992, 166.144, 166.272, 175.488, 190.08, - 191.872, 192, 193.28, 193.536, 213.376, 213.504, 225.664, 225.792, 243.2, 243.84, 256, - 264.448, 264.576, 264.704, 269.568, 274.816, 274.944, 276.096, 283.264, 294.784, 294.912, - 295.04, 295.168, 313.984, 325.504, 333.568, 335.872, 336.384 - }; - - var expected = new TimeRange(16, 45.184); - var actual = TimeRangeHelpers.FindContiguous(times, 2); - - Assert.Equal(expected, actual); - } - - /// - /// Tests that TimeRange intersections are detected correctly. - /// Tests each time range against a range of 5 to 10 seconds. - /// - [Theory] - [InlineData(1, 4, false)] // too early - [InlineData(4, 6, true)] // intersects on the left - [InlineData(7, 8, true)] // in the middle - [InlineData(9, 12, true)] // intersects on the right - [InlineData(13, 15, false)] // too late - public void TestTimeRangeIntersection(int start, int end, bool expected) - { - var large = new TimeRange(5, 10); - var testRange = new TimeRange(start, end); - - Assert.Equal(expected, large.Intersects(testRange)); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs deleted file mode 100644 index fd71c42..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using Xunit; - -namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; - -public class TestEdl -{ - // Test data is from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL - [Theory] - [InlineData(5.3, 7.1, EdlAction.Cut, "5.3 7.1 0")] - [InlineData(15, 16.7, EdlAction.Mute, "15 16.7 1")] - [InlineData(420, 822, EdlAction.CommercialBreak, "420 822 3")] - [InlineData(1, 255.3, EdlAction.SceneMarker, "1 255.3 2")] - [InlineData(1.123456789, 5.654647987, EdlAction.CommercialBreak, "1.12 5.65 3")] - public void TestEdlSerialization(double start, double end, EdlAction action, string expected) - { - var intro = MakeIntro(start, end); - var actual = intro.ToEdl(action); - - Assert.Equal(expected, actual); - } - - [Fact] - public void TestEdlInvalidSerialization() - { - Assert.Throws(() => { - var intro = MakeIntro(0, 5); - intro.ToEdl(EdlAction.None); - }); - } - - [Theory] - [InlineData("Death Note - S01E12 - Love.mkv", "Death Note - S01E12 - Love.edl")] - [InlineData("/full/path/to/file.rm", "/full/path/to/file.edl")] - public void TestEdlPath(string mediaPath, string edlPath) - { - Assert.Equal(edlPath, EdlManager.GetEdlPath(mediaPath)); - } - - private Intro MakeIntro(double start, double end) - { - return new Intro(Guid.Empty, new TimeRange(start, end)); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestWarnings.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestWarnings.cs deleted file mode 100644 index ac3f246..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestWarnings.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; - -using Xunit; - -public class TestFlags -{ - [Fact] - public void TestEmptyFlagSerialization() - { - WarningManager.Clear(); - Assert.Equal("None", WarningManager.GetWarnings()); - } - - [Fact] - public void TestSingleFlagSerialization() - { - WarningManager.Clear(); - WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton); - Assert.Equal("UnableToAddSkipButton", WarningManager.GetWarnings()); - } - - [Fact] - public void TestDoubleFlagSerialization() - { - WarningManager.Clear(); - WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton); - WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint); - WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint); - - Assert.Equal( - "UnableToAddSkipButton, InvalidChromaprintFingerprint", - WarningManager.GetWarnings()); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/README.txt b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/README.txt deleted file mode 100644 index 095d013..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/README.txt +++ /dev/null @@ -1,7 +0,0 @@ -The audio used in the fingerprinting unit tests is from Big Buck Bunny, attributed below. - -Both big_buck_bunny_intro.mp3 and big_buck_bunny_clip.mp3 are derived from Big Buck Bunny, (c) copyright 2008, Blender Foundation / www.bigbuckbunny.org. They are used under the Creative Commons Attribution 3.0 and the original source can be found at https://www.youtube.com/watch?v=YE7VzlLtp-4. - -Both files have been downmixed to two audio channels. -big_buck_bunny_intro.mp3 is from 5 to 30 seconds. -big_buck_bunny_clip.mp3 is from 0 to 60 seconds. diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/big_buck_bunny_clip.mp3 b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/big_buck_bunny_clip.mp3 deleted file mode 100644 index d7132d0..0000000 Binary files a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/big_buck_bunny_clip.mp3 and /dev/null differ diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/big_buck_bunny_intro.mp3 b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/big_buck_bunny_intro.mp3 deleted file mode 100644 index 95ecf7b..0000000 Binary files a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/audio/big_buck_bunny_intro.mp3 and /dev/null differ diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/.gitignore b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/.gitignore deleted file mode 100644 index 15c88d2..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -# Binaries -/verifier/verifier -/run_tests -/plugin_binaries/ - -# Wrapper configuration and base configuration files -config.json -/config/ - -# Timestamp reports -/reports/ - -# Selenium screenshots -selenium/screenshots/ diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/README.md b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/README.md deleted file mode 100644 index d0361be..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# End to end testing framework - -## wrapper - -The wrapper script (compiled as `run_tests`) runs multiple tests on Jellyfin servers to verify that the plugin works as intended. It tests: - -- Introduction timestamp accuracy (using `verifier`) -- Web interface functionality (using `selenium/main.py`) - -## verifier - -### Description - -This program is responsible for: -* Saving all discovered introduction timestamps into a report -* Comparing two reports against each other to find episodes that: - * Are missing introductions in both reports - * Have introductions in both reports, but with different timestamps - * Newly discovered introductions - * Introductions that were discovered previously, but not anymore -* Validating the schema of returned `Intro` objects from the `/IntroTimestamps` API endpoint - -### Usage examples -* Generate intro timestamp report from a local server: - * `./verifier -address http://127.0.0.1:8096 -key api_key` -* Generate intro timestamp report from a remote server, polling for task completion every 20 seconds: - * `./verifier -address https://example.com -key api_key -poll 20s -o example.json` -* Compare two previously generated reports: - * `./verifier -r1 v0.1.5.json -r2 v0.1.6.json` -* Validate the API schema for three episodes: - * `./verifier -address http://127.0.0.1:8096 -key api_key -validate id1,id2,id3` - -## Selenium web interface tests - -Selenium is used to verify that the plugin's web interface works as expected. It simulates a user: - -* Clicking the skip intro button - * Checks that clicking the button skips the intro and keeps playing the video -* Changing settings (will be added in the future) - * Maximum degree of parallelism - * Selecting libraries for analysis - * EDL settings - * Introduction requirements - * Auto skip - * Show/hide skip prompt -* Timestamp editor (will be added in the future) - * Displays timestamps - * Modifies timestamps - * Erases season timestamps -* Fingerprint visualizer (will be added in the future) - * Suggests shifts - * Visualizer canvas is drawn on diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/build.sh b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/build.sh deleted file mode 100755 index 8a29ab8..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/build.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -echo "[+] Building timestamp verifier" -(cd verifier && go build -o verifier) || exit 1 - -echo "[+] Building test wrapper" -(cd wrapper && go test ./... && go build -o ../run_tests) || exit 1 - -echo -echo "[+] All programs built successfully" diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/config_sample.jsonc b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/config_sample.jsonc deleted file mode 100644 index a800145..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/config_sample.jsonc +++ /dev/null @@ -1,22 +0,0 @@ -{ - "common": { - "library": "/full/path/to/test/library/on/host/TV", - "episode": "Episode title to search for" - }, - "servers": [ - { - "comment": "Optional comment to identify this server", - "image": "ghcr.io/confusedpolarbear/jellyfin-intro-skipper:latest", - "username": "admin", - "password": "hunter2", - "browsers": [ - "chrome", - "firefox" - ], // supported values are "chrome" and "firefox". - "tests": [ - "skip_button", // test skip intro button - "settings" // test plugin administration page - ] - } - ] -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/docker-compose.yml b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/docker-compose.yml deleted file mode 100644 index 24da6d2..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/docker-compose.yml +++ /dev/null @@ -1,33 +0,0 @@ -version: "3" -services: - chrome: - image: selenium/standalone-chrome:106.0 - shm_size: 2gb - ports: - - 4444:4444 - environment: - - SE_NODE_SESSION_TIMEOUT=10 - - firefox: - image: selenium/standalone-firefox:105.0 - shm_size: 2gb - ports: - - 4445:4444 - environment: - - SE_NODE_SESSION_TIMEOUT=10 - - chrome_video: - image: selenium/video - environment: - - DISPLAY_CONTAINER_NAME=chrome - - FILE_NAME=chrome_video.mp4 - volumes: - - /tmp/selenium/videos:/videos - - firefox_video: - image: selenium/video - environment: - - DISPLAY_CONTAINER_NAME=firefox - - FILE_NAME=firefox_video.mp4 - volumes: - - /tmp/selenium/videos:/videos diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/selenium/main.py b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/selenium/main.py deleted file mode 100644 index ceaa25b..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/selenium/main.py +++ /dev/null @@ -1,203 +0,0 @@ -import argparse, os, time - -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys - - -# Driver function -def main(): - # Parse CLI arguments and store in a dictionary - parser = argparse.ArgumentParser() - parser.add_argument("-host", help="Jellyfin server address with protocol and port.") - parser.add_argument("-username", help="Username.") - parser.add_argument("-password", help="Password.") - parser.add_argument("-name", help="Name of episode to search for.") - parser.add_argument( - "--tests", help="Space separated list of Selenium tests to run.", type=str, nargs="+" - ) - parser.add_argument( - "--browsers", - help="Space separated list of browsers to run tests with.", - type=str, - nargs="+", - choices=["chrome", "firefox"], - ) - args = parser.parse_args() - - server = { - "host": args.host, - "username": args.username, - "password": args.password, - "episode": args.name, - "browsers": args.browsers, - "tests": args.tests, - } - - # Print the server info for debugging and run the test - print() - print(f"Browsers: {server['browsers']}") - print(f"Address: {server['host']}") - print(f"Username: {server['username']}") - print(f"Episode: \"{server['episode']}\"") - print(f"Tests: {server['tests']}") - print() - - # Setup the list of drivers to run tests with - if server["browsers"] is None: - print("[!] --browsers is required") - exit(1) - - drivers = [] - if "chrome" in server["browsers"]: - drivers = [("http://127.0.0.1:4444", "Chrome")] - if "firefox" in server["browsers"]: - drivers.append(("http://127.0.0.1:4445", "Firefox")) - - # Test with all selected drivers - for driver in drivers: - print(f"[!] Starting new test run using {driver[1]}") - test_server(server, driver[0], driver[1]) - print() - - -# Main server test function -def test_server(server, executor, driver_type): - # Configure Selenium to use a remote driver - print(f"[+] Configuring Selenium to use executor {executor} of type {driver_type}") - - opts = None - if driver_type == "Chrome": - opts = webdriver.ChromeOptions() - elif driver_type == "Firefox": - opts = webdriver.FirefoxOptions() - else: - raise ValueError(f"Unknown driver type {driver_type}") - - driver = webdriver.Remote(command_executor=executor, options=opts) - - try: - # Wait up to two seconds when finding an element before reporting failure - driver.implicitly_wait(2) - - # Login to Jellyfin - driver.get(make_url(server, "/")) - - print(f"[+] Authenticating as {server['username']}") - login(driver, server) - - if "skip_button" in server["tests"]: - # Play the user specified episode and verify skip intro button functionality. This episode is expected to: - # * already have been analyzed for an introduction - # * have an introduction at the beginning of the episode - print("[+] Testing skip intro button") - test_skip_button(driver, server) - - print("[+] All tests completed successfully") - finally: - # Unconditionally end the Selenium session - driver.quit() - - -def login(driver, server): - # Append the Enter key to the password to submit the form - us = server["username"] - pw = server["password"] + Keys.ENTER - - # Fill out and submit the login form - driver.find_element(By.ID, "txtManualName").send_keys(us) - driver.find_element(By.ID, "txtManualPassword").send_keys(pw) - - -def test_skip_button(driver, server): - print(f" [+] Searching for episode \"{server['episode']}\"") - - search = driver.find_element(By.CSS_SELECTOR, ".headerSearchButton span.search") - - if driver.capabilities["browserName"] == "firefox": - # Work around a FF bug where the search element isn't considered clickable right away - time.sleep(1) - - # Click the search button - search.click() - - # Type the episode name - driver.find_element(By.CSS_SELECTOR, ".searchfields-txtSearch").send_keys( - server["episode"] - ) - - # Click the first episode in the search results - driver.find_element( - By.CSS_SELECTOR, ".searchResults button[data-type='Episode']" - ).click() - - # Wait for the episode page to finish loading by searching for the episode description (overview) - driver.find_element(By.CSS_SELECTOR, ".overview") - - print(f" [+] Waiting for playback to start") - - # Click the play button in the toolbar - driver.find_element( - By.CSS_SELECTOR, "div.mainDetailButtons span.play_arrow" - ).click() - - # Wait for playback to start by searching for the lower OSD control bar - driver.find_element(By.CSS_SELECTOR, ".osdControls") - - # Let the video play a little bit so the position before clicking the button can be logged - print(" [+] Playing video") - time.sleep(2) - screenshot(driver, "skip_button_pre_skip") - assert_video_playing(driver) - - # Find the skip intro button and click it, logging the new video position after the seek is preformed - print(" [+] Clicking skip intro button") - driver.find_element(By.CSS_SELECTOR, "div#skipIntro").click() - time.sleep(1) - screenshot(driver, "skip_button_post_skip") - assert_video_playing(driver) - - # Keep playing the video for a few seconds to ensure that: - # * the intro was successfully skipped - # * video playback continued automatically post button click - print(" [+] Verifying post skip position") - time.sleep(4) - - screenshot(driver, "skip_button_post_play") - assert_video_playing(driver) - - -# Utility functions -def make_url(server, url): - final = server["host"] + url - print(f"[+] Navigating to {final}") - return final - - -def screenshot(driver, filename): - dest = f"screenshots/{filename}.png" - driver.save_screenshot(dest) - - -# Returns the current video playback position and if the video is paused. -# Will raise an exception if playback is paused as the video shouldn't ever pause when using this plugin. -def assert_video_playing(driver): - ret = driver.execute_script( - """ - const video = document.querySelector("video"); - return { - "position": video.currentTime, - "paused": video.paused - }; - """ - ) - - if ret["paused"]: - raise Exception("Video should not be paused") - - print(f" [+] Video playback position: {ret['position']}") - - return ret - - -main() diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/selenium/requirements.txt b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/selenium/requirements.txt deleted file mode 100644 index 7978165..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/selenium/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -selenium >= 4.3.0 diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/go.mod b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/go.mod deleted file mode 100644 index 0bc6481..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/confusedpolarbear/intro_skipper_verifier - -go 1.17 diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/http.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/http.go deleted file mode 100644 index e3178a8..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/http.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/confusedpolarbear/intro_skipper_verifier/structs" -) - -// Gets the contents of the provided URL or panics. -func SendRequest(method, url, apiKey string) []byte { - http.DefaultClient.Timeout = 10 * time.Second - - // Construct the request - req, err := http.NewRequest(method, url, nil) - if err != nil { - panic(err) - } - - // Include the authorization token - req.Header.Set("Authorization", fmt.Sprintf(`MediaBrowser Token="%s"`, apiKey)) - - // Send the request - res, err := http.DefaultClient.Do(req) - - if !strings.Contains(url, "hideUrl") { - fmt.Printf("[+] %s %s: %d\n", method, url, res.StatusCode) - } - - // Panic if any error occurred - if err != nil { - panic(err) - } - - // Check for API key validity - if res.StatusCode == http.StatusUnauthorized { - panic("Server returned 401 (Unauthorized). Check API key validity and try again.") - } - - // Read and return the entire body - defer res.Body.Close() - body, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - return body -} - -func GetServerInfo(hostAddress, apiKey string) structs.PublicInfo { - var info structs.PublicInfo - - fmt.Println("[+] Getting server information") - rawInfo := SendRequest("GET", hostAddress+"/System/Info/Public", apiKey) - - if err := json.Unmarshal(rawInfo, &info); err != nil { - panic(err) - } - - return info -} - -func GetPluginConfiguration(hostAddress, apiKey string) structs.PluginConfiguration { - var config structs.PluginConfiguration - - fmt.Println("[+] Getting plugin configuration") - rawConfig := SendRequest("GET", hostAddress+"/Plugins/c83d86bb-a1e0-4c35-a113-e2101cf4ee6b/Configuration", apiKey) - - if err := json.Unmarshal(rawConfig, &config); err != nil { - panic(err) - } - - return config -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/main.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/main.go deleted file mode 100644 index 5e746ee..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/main.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "flag" - "time" -) - -func flags() { - // Report generation - hostAddress := flag.String("address", "", "Address of Jellyfin server to extract intro information from.") - apiKey := flag.String("key", "", "Administrator API key to authenticate with.") - keepTimestamps := flag.Bool("keep", false, "Keep the current timestamps instead of erasing and reanalyzing.") - pollInterval := flag.Duration("poll", 10*time.Second, "Interval to poll task completion at.") - reportDestination := flag.String("o", "", "Report destination filename. Defaults to intros-ADDRESS-TIMESTAMP.json.") - - // Report comparison - report1 := flag.String("r1", "", "First report.") - report2 := flag.String("r2", "", "Second report.") - - // API schema validator - ids := flag.String("validate", "", "Comma separated item ids to validate the API schema for.") - - // Print usage examples - flag.CommandLine.Usage = func() { - flag.CommandLine.Output().Write([]byte("Flags:\n")) - flag.PrintDefaults() - - usage := "\nUsage:\n" + - "Generate intro timestamp report from a local server:\n" + - "./verifier -address http://127.0.0.1:8096 -key api_key\n\n" + - - "Generate intro timestamp report from a remote server, polling for task completion every 20 seconds:\n" + - "./verifier -address https://example.com -key api_key -poll 20s -o example.json\n\n" + - - "Compare two previously generated reports:\n" + - "./verifier -r1 v0.1.5.json -r2 v0.1.6.json\n\n" + - - "Validate the API schema for some item ids:\n" + - "./verifier -address http://127.0.0.1:8096 -key api_key -validate id1,id2,id3\n" - - flag.CommandLine.Output().Write([]byte(usage)) - } - - flag.Parse() - - if *hostAddress != "" && *apiKey != "" { - if *ids == "" { - generateReport(*hostAddress, *apiKey, *reportDestination, *keepTimestamps, *pollInterval) - } else { - validateApiSchema(*hostAddress, *apiKey, *ids) - } - - } else if *report1 != "" && *report2 != "" { - compareReports(*report1, *report2, *reportDestination) - - } else { - panic("Either (-address and -key) or (-r1 and -r2) are required.") - } -} - -func main() { - flags() -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report.html b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report.html deleted file mode 100644 index f030b0d..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report.html +++ /dev/null @@ -1,389 +0,0 @@ - - - - - - - - - - -
-

Intro Timestamp Differential

- -
-

First report

- - {{ block "ReportInfo" .OldReport }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Path{{ .Path }}
Jellyfin{{ .ServerInfo.Version }} on {{ .ServerInfo.OperatingSystem }}
Analysis Settings{{ printAnalysisSettings .PluginConfig }}
Introduction Requirements{{ printIntroductionReqs .PluginConfig }}
Start time{{ printTime .StartedAt }}
End time{{ printTime .FinishedAt }}
Duration{{ printDuration .Runtime }}
- {{ end }} -
- -
-

Second report

- - {{ template "ReportInfo" .NewReport }} -
-
- -
-

Statistics

- - - - - - - - - - - - - - - - - - - - - - - - -
Total episodes
Never found
Changed
Gains
Losses
-
- -
-

Settings

- -
- -
- - -
- - -
-
-
- - {{/* store a reference to the data before the range query */}} - {{ $p := . }} - - {{/* sort the show names and iterate over them */}} - {{ range $name := sortShows .OldReport.Shows }} -
-
- {{/* get the unsorted seasons for this show */}} - {{ $seasons := index $p.OldReport.Shows $name }} - - {{/* log the show name and number of seasons */}} - - - {{ $name }} - - - - -
- {{/* sort the seasons to ensure they display in numerical order */}} - {{ range $seasonNumber := (sortSeasons $seasons) }} -
-
- - - Season {{ $seasonNumber }} - - - - - {{/* compare each episode in the old report to the same episode in the new report */}} - {{ range $episode := index $seasons $seasonNumber }} - - {{/* lookup and compare both episodes */}} - {{ $comparison := compareEpisodes $episode.EpisodeId $p }} - {{ $old := $comparison.Old }} - {{ $new := $comparison.New }} - - {{/* set attributes indicating if an intro was found in the old and new reports */}} -
-

{{ $episode.Title }}

- -

- Old: {{ $old.FormattedStart }} - {{ $old.FormattedEnd }} - ({{ $old.Duration }}) - (valid: {{ $old.Valid }})
- - New: {{ $new.FormattedStart }} - {{ $new.FormattedEnd }} - ({{ $new.Duration }}) - (valid: {{ $new.Valid }})
- - {{ if ne $comparison.WarningShort "okay" }} - Warning: {{ $comparison.Warning }} - {{ end }} -

- -
-
- {{ end }} -
-
- {{ end }} -
-
-
- {{ end }} - - - - - diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_comparison.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_comparison.go deleted file mode 100644 index 92c62e1..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_comparison.go +++ /dev/null @@ -1,139 +0,0 @@ -package main - -import ( - _ "embed" - "encoding/json" - "fmt" - "html/template" - "math" - "os" - "time" - - "github.com/confusedpolarbear/intro_skipper_verifier/structs" -) - -//go:embed report.html -var reportTemplate []byte - -func compareReports(oldReportPath, newReportPath, destination string) { - start := time.Now() - - // Populate the destination filename if none was provided - if destination == "" { - destination = fmt.Sprintf("report-%d.html", start.Unix()) - } - - // Open the report for writing - f, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - panic(err) - } else { - defer f.Close() - } - - fmt.Printf("Started at: %s\n", start.Format(time.RFC1123)) - fmt.Printf("First report: %s\n", oldReportPath) - fmt.Printf("Second report: %s\n", newReportPath) - fmt.Printf("Destination: %s\n\n", destination) - - // Unmarshal both reports - oldReport, newReport := unmarshalReport(oldReportPath), unmarshalReport(newReportPath) - - fmt.Println("[+] Comparing reports") - - // Setup a function map with helper functions to use in the template - tmp := template.New("report") - - funcs := make(template.FuncMap) - - funcs["printTime"] = func(t time.Time) string { - return t.Format(time.RFC1123) - } - - funcs["printDuration"] = func(d time.Duration) string { - return d.Round(time.Second).String() - } - - funcs["printAnalysisSettings"] = func(pc structs.PluginConfiguration) string { - return pc.AnalysisSettings() - } - - funcs["printIntroductionReqs"] = func(pc structs.PluginConfiguration) string { - return pc.IntroductionRequirements() - } - - funcs["sortShows"] = templateSortShows - funcs["sortSeasons"] = templateSortSeason - funcs["compareEpisodes"] = templateCompareEpisodes - tmp.Funcs(funcs) - - // Load the template or panic - report := template.Must(tmp.Parse(string(reportTemplate))) - - err = report.Execute(f, - structs.TemplateReportData{ - OldReport: oldReport, - NewReport: newReport, - }) - - if err != nil { - panic(err) - } - - // Log success - fmt.Printf("[+] Reports successfully compared in %s\n", time.Since(start).Round(time.Millisecond)) -} - -func unmarshalReport(path string) structs.Report { - // Read the provided report - contents, err := os.ReadFile(path) - if err != nil { - panic(err) - } - - // Unmarshal - var report structs.Report - if err := json.Unmarshal(contents, &report); err != nil { - panic(err) - } - - // Setup maps and template data for later use - report.Path = path - report.Shows = make(map[string]structs.Seasons) - report.IntroMap = make(map[string]structs.Intro) - - // Sort episodes by show and season - for _, intro := range report.Intros { - // Round the duration to the nearest second to avoid showing 8 decimal places in the report - intro.Duration = float32(math.Round(float64(intro.Duration))) - - // Pretty print the intro start and end times - intro.FormattedStart = (time.Duration(intro.IntroStart) * time.Second).String() - intro.FormattedEnd = (time.Duration(intro.IntroEnd) * time.Second).String() - - show, season := intro.Series, intro.Season - - // If this show hasn't been seen before, allocate space for it - if _, ok := report.Shows[show]; !ok { - report.Shows[show] = make(structs.Seasons) - } - - // Store this intro in the season of this show - episodes := report.Shows[show][season] - episodes = append(episodes, intro) - report.Shows[show][season] = episodes - - // Store a reference to this intro in a lookup table - report.IntroMap[intro.EpisodeId] = intro - } - - // Print report info - fmt.Printf("Report %s:\n", path) - fmt.Printf("Generated with Jellyfin %s running on %s\n", report.ServerInfo.Version, report.ServerInfo.OperatingSystem) - fmt.Printf("Analysis settings: %s\n", report.PluginConfig.AnalysisSettings()) - fmt.Printf("Introduction reqs: %s\n", report.PluginConfig.IntroductionRequirements()) - fmt.Printf("Episodes analyzed: %d\n", len(report.Intros)) - fmt.Println() - - return report -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_comparison_util.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_comparison_util.go deleted file mode 100644 index ec51c6a..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_comparison_util.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "fmt" - "math" - "sort" - - "github.com/confusedpolarbear/intro_skipper_verifier/structs" -) - -// report template helper functions - -// Sort show names alphabetically -func templateSortShows(shows map[string]structs.Seasons) []string { - var showNames []string - - for show := range shows { - showNames = append(showNames, show) - } - - sort.Strings(showNames) - - return showNames -} - -// Sort season numbers -func templateSortSeason(show structs.Seasons) []int { - var keys []int - - for season := range show { - keys = append(keys, season) - } - - sort.Ints(keys) - - return keys -} - -// Compare the episode with the provided ID in the old report to the episode in the new report. -func templateCompareEpisodes(id string, reports structs.TemplateReportData) structs.IntroPair { - var pair structs.IntroPair - var tolerance int = 5 - - // Locate both episodes - pair.Old = reports.OldReport.IntroMap[id] - pair.New = reports.NewReport.IntroMap[id] - - // Mark the timestamps as similar if they are within a few seconds of each other - similar := func(oldTime, newTime float32) bool { - diff := math.Abs(float64(newTime) - float64(oldTime)) - return diff <= float64(tolerance) - } - - if pair.Old.Valid && !pair.New.Valid { - // If an intro was found previously, but not now, flag it - pair.WarningShort = "only_previous" - pair.Warning = "Introduction found in previous report, but not the current one" - - } else if !pair.Old.Valid && pair.New.Valid { - // If an intro was not found previously, but found now, flag it - pair.WarningShort = "improvement" - pair.Warning = "New introduction discovered" - - } else if !pair.Old.Valid && !pair.New.Valid { - // If an intro has never been found for this episode - pair.WarningShort = "missing" - pair.Warning = "No introduction has ever been found for this episode" - - } else if !similar(pair.Old.IntroStart, pair.New.IntroStart) || !similar(pair.Old.IntroEnd, pair.New.IntroEnd) { - // If the intro timestamps are too different, flag it - pair.WarningShort = "different" - pair.Warning = fmt.Sprintf("Timestamps differ by more than %d seconds", tolerance) - - } else { - // No warning was generated - pair.WarningShort = "okay" - pair.Warning = "Okay" - } - - return pair -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_generator.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_generator.go deleted file mode 100644 index 2c4fb3b..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/report_generator.go +++ /dev/null @@ -1,180 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "strings" - "time" - - "github.com/confusedpolarbear/intro_skipper_verifier/structs" -) - -var spinners []string -var spinnerIndex int - -func generateReport(hostAddress, apiKey, reportDestination string, keepTimestamps bool, pollInterval time.Duration) { - start := time.Now() - - // Setup the spinner - spinners = strings.Split("⣷⣯⣟⡿⢿⣻⣽⣾", "") - spinnerIndex = -1 // start the spinner on the first graphic - - // Setup the filename to save intros to - if reportDestination == "" { - reportDestination = fmt.Sprintf("intros-%s-%d.json", hostAddress, time.Now().Unix()) - reportDestination = strings.ReplaceAll(reportDestination, "http://", "") - reportDestination = strings.ReplaceAll(reportDestination, "https://", "") - } - - // Ensure the file is writable - if err := os.WriteFile(reportDestination, nil, 0600); err != nil { - panic(err) - } - - fmt.Printf("Started at: %s\n", start.Format(time.RFC1123)) - fmt.Printf("Address: %s\n", hostAddress) - fmt.Printf("Destination: %s\n", reportDestination) - fmt.Println() - - // Get Jellyfin server information and plugin configuration - info := GetServerInfo(hostAddress, apiKey) - config := GetPluginConfiguration(hostAddress, apiKey) - fmt.Println() - - fmt.Printf("Jellyfin OS: %s\n", info.OperatingSystem) - fmt.Printf("Jellyfin version: %s\n", info.Version) - fmt.Printf("Analysis settings: %s\n", config.AnalysisSettings()) - fmt.Printf("Introduction reqs: %s\n", config.IntroductionRequirements()) - fmt.Printf("Erase timestamps: %t\n", !keepTimestamps) - fmt.Println() - - // If not keeping timestamps, run the fingerprint task. - // Otherwise, log that the task isn't being run - if !keepTimestamps { - runAnalysisAndWait(hostAddress, apiKey, pollInterval) - } else { - fmt.Println("[+] Using previously discovered intros") - } - fmt.Println() - - // Save all intros from the server - fmt.Println("[+] Saving intros") - - var report structs.Report - rawIntros := SendRequest("GET", hostAddress+"/Intros/All", apiKey) - if err := json.Unmarshal(rawIntros, &report.Intros); err != nil { - panic(err) - } - - // Calculate the durations of all intros - for i := range report.Intros { - intro := report.Intros[i] - intro.Duration = intro.IntroEnd - intro.IntroStart - report.Intros[i] = intro - } - - fmt.Println() - fmt.Println("[+] Saving report") - - // Store timing data, server information, and plugin configuration - report.StartedAt = start - report.FinishedAt = time.Now() - report.Runtime = report.FinishedAt.Sub(report.StartedAt) - report.ServerInfo = info - report.PluginConfig = config - - // Marshal the report - marshalled, err := json.Marshal(report) - if err != nil { - panic(err) - } - - if err := os.WriteFile(reportDestination, marshalled, 0600); err != nil { - panic(err) - } - - // Change report permissions - exec.Command("chown", "1000:1000", reportDestination).Run() - - fmt.Println("[+] Done") -} - -func runAnalysisAndWait(hostAddress, apiKey string, pollInterval time.Duration) { - var taskId string = "" - - type taskInfo struct { - State string - CurrentProgressPercentage int - } - - fmt.Println("[+] Erasing previously discovered intros") - SendRequest("POST", hostAddress+"/Intros/EraseTimestamps", apiKey) - fmt.Println() - - var taskIds = []string{ - "f64d8ad58e3d7b98548e1a07697eb100", // v0.1.8 - "8863329048cc357f7dfebf080f2fe204", - "6adda26c5261c40e8fa4a7e7df568be2"} - - fmt.Println("[+] Starting analysis task") - for _, id := range taskIds { - body := SendRequest("POST", hostAddress+"/ScheduledTasks/Running/"+id, apiKey) - fmt.Println() - - // If the scheduled task was found, store the task ID for later - if !strings.Contains(string(body), "Not Found") { - taskId = id - break - } - } - - if taskId == "" { - panic("unable to find scheduled task") - } - - fmt.Println("[+] Waiting for analysis task to complete") - fmt.Print("[+] Episodes analyzed: 0%") - - var info taskInfo // Last known scheduled task state - var lastQuery time.Time // Time the task info was last updated - - for { - time.Sleep(500 * time.Millisecond) - - // Update the spinner - if spinnerIndex++; spinnerIndex >= len(spinners) { - spinnerIndex = 0 - } - - fmt.Printf("\r[%s] Episodes analyzed: %d%%", spinners[spinnerIndex], info.CurrentProgressPercentage) - - if info.CurrentProgressPercentage == 100 { - fmt.Printf("\r[+]") // reset the spinner - fmt.Println() - break - } - - // Get the latest task state & unmarshal (only if enough time has passed since the last update) - if time.Since(lastQuery) <= pollInterval { - continue - } - - lastQuery = time.Now() - - raw := SendRequest("GET", hostAddress+"/ScheduledTasks/"+taskId+"?hideUrl=1", apiKey) - - if err := json.Unmarshal(raw, &info); err != nil { - fmt.Printf("[!] Unable to unmarshal response into taskInfo struct: %s\n", err) - fmt.Printf("%s\n", raw) - continue - } - - // Print the latest task state - switch info.State { - case "Idle": - info.CurrentProgressPercentage = 100 - } - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/schema_validation.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/schema_validation.go deleted file mode 100644 index 0ae77dd..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/schema_validation.go +++ /dev/null @@ -1,116 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/confusedpolarbear/intro_skipper_verifier/structs" -) - -// Given a comma separated list of item IDs, validate the returned API schema. -func validateApiSchema(hostAddress, apiKey, rawIds string) { - // Iterate over the raw item IDs and validate the schema of API responses - ids := strings.Split(rawIds, ",") - - start := time.Now() - - fmt.Printf("Started at: %s\n", start.Format(time.RFC1123)) - fmt.Printf("Address: %s\n", hostAddress) - fmt.Println() - - // Get Jellyfin server information - info := GetServerInfo(hostAddress, apiKey) - fmt.Println() - - fmt.Printf("Jellyfin OS: %s\n", info.OperatingSystem) - fmt.Printf("Jellyfin version: %s\n", info.Version) - fmt.Println() - - for _, id := range ids { - fmt.Printf("[+] Validating item %s\n", id) - - fmt.Println(" [+] Validating API v1 (implicitly versioned)") - intro, schema := getTimestampsV1(hostAddress, apiKey, id, "") - validateV1Intro(id, intro, schema) - - fmt.Println(" [+] Validating API v1 (explicitly versioned)") - intro, schema = getTimestampsV1(hostAddress, apiKey, id, "v1") - validateV1Intro(id, intro, schema) - - fmt.Println() - } - - fmt.Printf("Validated %d items in %s\n", len(ids), time.Since(start).Round(time.Millisecond)) -} - -// Validates the returned intro object, panicking on any error. -func validateV1Intro(id string, intro structs.Intro, schema map[string]interface{}) { - // Validate the item ID - if intro.EpisodeId != id { - panic(fmt.Sprintf("Intro struct has incorrect item ID. Expected '%s', found '%s'", id, intro.EpisodeId)) - } - - // Validate the intro start and end times - if intro.IntroStart < 0 || intro.IntroEnd < 0 { - panic("Intro struct has a negative intro start or end time") - } - - if intro.ShowSkipPromptAt > intro.IntroStart { - panic("Intro struct show prompt time is after intro start") - } - - if intro.HideSkipPromptAt > intro.IntroEnd { - panic("Intro struct hide prompt time is after intro end") - } - - // Validate the intro duration - if duration := intro.IntroEnd - intro.IntroStart; duration < 15 { - panic(fmt.Sprintf("Intro struct has duration %0.2f but the minimum allowed is 15", duration)) - } - - // Ensure the intro is marked as valid. - if !intro.Valid { - panic("Intro struct is not marked as valid") - } - - // Check for any extraneous properties - allowedProperties := []string{"EpisodeId", "Valid", "IntroStart", "IntroEnd", "ShowSkipPromptAt", "HideSkipPromptAt"} - - for schemaKey := range schema { - okay := false - - for _, allowed := range allowedProperties { - if allowed == schemaKey { - okay = true - break - } - } - - if !okay { - panic(fmt.Sprintf("Intro object contains unknown key '%s'", schemaKey)) - } - } -} - -// Gets the timestamps for the provided item or panics. -func getTimestampsV1(hostAddress, apiKey, id, version string) (structs.Intro, map[string]interface{}) { - var rawResponse map[string]interface{} - var intro structs.Intro - - // Make an authenticated GET request to {Host}/Episode/{ItemId}/IntroTimestamps/{Version} - raw := SendRequest("GET", fmt.Sprintf("%s/Episode/%s/IntroTimestamps/%s?hideUrl=1", hostAddress, id, version), apiKey) - - // Unmarshal the response as a version 1 API response, ignoring any unknown fields. - if err := json.Unmarshal(raw, &intro); err != nil { - panic(err) - } - - // Second, unmarshal the response into a map so that any unknown fields can be detected and alerted on. - if err := json.Unmarshal(raw, &rawResponse); err != nil { - panic(err) - } - - return intro, rawResponse -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/intro.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/intro.go deleted file mode 100644 index 1a6ba2c..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/intro.go +++ /dev/null @@ -1,20 +0,0 @@ -package structs - -type Intro struct { - EpisodeId string - - Series string - Season int - Title string - - IntroStart float32 - IntroEnd float32 - Duration float32 - Valid bool - - FormattedStart string - FormattedEnd string - - ShowSkipPromptAt float32 - HideSkipPromptAt float32 -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/plugin_configuration.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/plugin_configuration.go deleted file mode 100644 index 594e910..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/plugin_configuration.go +++ /dev/null @@ -1,44 +0,0 @@ -package structs - -import ( - "fmt" - "strings" -) - -type PluginConfiguration struct { - CacheFingerprints bool - MaxParallelism int - SelectedLibraries string - - AnalysisPercent int - AnalysisLengthLimit int - MinimumIntroDuration int -} - -func (c PluginConfiguration) AnalysisSettings() string { - // If no libraries have been selected, display a star. - // Otherwise, quote each library before displaying the slice. - var libs []string - if c.SelectedLibraries == "" { - libs = []string{"*"} - } else { - for _, tmp := range strings.Split(c.SelectedLibraries, ",") { - tmp = `"` + strings.TrimSpace(tmp) + `"` - libs = append(libs, tmp) - } - } - - return fmt.Sprintf( - "cfp=%t thr=%d lbs=%v", - c.CacheFingerprints, - c.MaxParallelism, - libs) -} - -func (c PluginConfiguration) IntroductionRequirements() string { - return fmt.Sprintf( - "per=%d%% max=%dm min=%ds", - c.AnalysisPercent, - c.AnalysisLengthLimit, - c.MinimumIntroDuration) -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/public_info.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/public_info.go deleted file mode 100644 index 901307d..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/public_info.go +++ /dev/null @@ -1,6 +0,0 @@ -package structs - -type PublicInfo struct { - Version string - OperatingSystem string -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/report.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/report.go deleted file mode 100644 index 5e8a3b3..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/verifier/structs/report.go +++ /dev/null @@ -1,48 +0,0 @@ -package structs - -import "time" - -type Seasons map[int][]Intro - -type Report struct { - Path string `json:"-"` - - StartedAt time.Time - FinishedAt time.Time - Runtime time.Duration - - ServerInfo PublicInfo - PluginConfig PluginConfiguration - - Intros []Intro - - // Intro lookup table. Only populated when loading a report. - IntroMap map[string]Intro `json:"-"` - - // Intros which have been sorted by show and season number. Only populated when loading a report. - Shows map[string]Seasons `json:"-"` -} - -// Data passed to the report template. -type TemplateReportData struct { - // First report. - OldReport Report - - // Second report. - NewReport Report -} - -// A pair of introductions from an old and new reports. -type IntroPair struct { - Old Intro - New Intro - - // Recognized warning types: - // * okay: no warning - // * different: timestamps are too dissimilar - // * only_previous: introduction found in old report but not new one - WarningShort string - - // If this pair of intros is not okay, a short description about the cause - Warning string -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/exec.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/exec.go deleted file mode 100644 index 488cd52..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/exec.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "bufio" - "context" - "fmt" - "io" - "os/exec" - "regexp" - "strings" - "time" -) - -// Run an external program -func RunProgram(program string, args []string, timeout time.Duration) { - // Flag if we are starting or stopping a container - managingContainer := program == "docker" - - // Create context and command - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - cmd := exec.CommandContext(ctx, program, args...) - - // Stringify and censor the program's arguments - strArgs := redactString(strings.Join(args, " ")) - fmt.Printf(" [+] Running %s %s\n", program, strArgs) - - // Setup pipes - stdout, err := cmd.StdoutPipe() - if err != nil { - panic(err) - } - - stderr, err := cmd.StderrPipe() - if err != nil { - panic(err) - } - - // Start the command - if err := cmd.Start(); err != nil { - panic(err) - } - - // Stream any messages to the terminal - for _, r := range []io.Reader{stdout, stderr} { - // Don't log stdout from the container - if managingContainer && r == stdout { - continue - } - - scanner := bufio.NewScanner(r) - scanner.Split(bufio.ScanRunes) - - for scanner.Scan() { - fmt.Print(scanner.Text()) - } - } -} - -// Redacts sensitive command line arguments. -func redactString(raw string) string { - redactionRegex := regexp.MustCompilePOSIX(`-(user|pass|key) [^ ]+`) - return redactionRegex.ReplaceAllString(raw, "-$1 REDACTED") -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/exec_test.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/exec_test.go deleted file mode 100644 index ba25cae..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/exec_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import "testing" - -func TestStringRedaction(t *testing.T) { - raw := "-key deadbeef -first second -user admin -third fourth -pass hunter2" - expected := "-key REDACTED -first second -user REDACTED -third fourth -pass REDACTED" - actual := redactString(raw) - - if expected != actual { - t.Errorf(`String was redacted incorrectly: "%s"`, actual) - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/go.mod b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/go.mod deleted file mode 100644 index d3ab43e..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/confusedpolarbear/intro_skipper_wrapper - -go 1.17 diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/library.json b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/library.json deleted file mode 100644 index 5c35999..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/library.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "LibraryOptions": { - "EnableArchiveMediaFiles": false, - "EnablePhotos": false, - "EnableRealtimeMonitor": false, - "ExtractChapterImagesDuringLibraryScan": false, - "EnableChapterImageExtraction": false, - "EnableInternetProviders": false, - "SaveLocalMetadata": false, - "EnableAutomaticSeriesGrouping": false, - "PreferredMetadataLanguage": "", - "MetadataCountryCode": "", - "SeasonZeroDisplayName": "Specials", - "AutomaticRefreshIntervalDays": 0, - "EnableEmbeddedTitles": false, - "EnableEmbeddedEpisodeInfos": false, - "AllowEmbeddedSubtitles": "AllowAll", - "SkipSubtitlesIfEmbeddedSubtitlesPresent": false, - "SkipSubtitlesIfAudioTrackMatches": false, - "SaveSubtitlesWithMedia": true, - "RequirePerfectSubtitleMatch": true, - "AutomaticallyAddToCollection": false, - "MetadataSavers": [], - "TypeOptions": [ - { - "Type": "Series", - "MetadataFetchers": [ - "TheMovieDb", - "The Open Movie Database" - ], - "MetadataFetcherOrder": [ - "TheMovieDb", - "The Open Movie Database" - ], - "ImageFetchers": [ - "TheMovieDb" - ], - "ImageFetcherOrder": [ - "TheMovieDb" - ] - }, - { - "Type": "Season", - "MetadataFetchers": [ - "TheMovieDb" - ], - "MetadataFetcherOrder": [ - "TheMovieDb" - ], - "ImageFetchers": [ - "TheMovieDb" - ], - "ImageFetcherOrder": [ - "TheMovieDb" - ] - }, - { - "Type": "Episode", - "MetadataFetchers": [ - "TheMovieDb", - "The Open Movie Database" - ], - "MetadataFetcherOrder": [ - "TheMovieDb", - "The Open Movie Database" - ], - "ImageFetchers": [ - "TheMovieDb", - "The Open Movie Database", - "Embedded Image Extractor", - "Screen Grabber" - ], - "ImageFetcherOrder": [ - "TheMovieDb", - "The Open Movie Database", - "Embedded Image Extractor", - "Screen Grabber" - ] - } - ], - "LocalMetadataReaderOrder": [ - "Nfo" - ], - "SubtitleDownloadLanguages": [], - "DisabledSubtitleFetchers": [], - "SubtitleFetcherOrder": [], - "PathInfos": [ - { - "Path": "/media/TV" - } - ] - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/main.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/main.go deleted file mode 100644 index 3d1a477..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/main.go +++ /dev/null @@ -1,380 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "crypto/rand" - "encoding/hex" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "os" - "path" - "strings" - "time" -) - -// IP address to use when connecting to local containers. -var containerAddress string - -// Path to compiled plugin DLL to install in local containers. -var pluginPath string - -// Randomly generated password used to setup container with. -var containerPassword string - -func flags() { - flag.StringVar(&pluginPath, "dll", "", "Path to plugin DLL to install in container images.") - flag.StringVar(&containerAddress, "caddr", "", "IP address to use when connecting to local containers.") - flag.Parse() - - // Randomize the container's password - rawPassword := make([]byte, 32) - if _, err := rand.Read(rawPassword); err != nil { - panic(err) - } - - containerPassword = hex.EncodeToString(rawPassword) -} - -func main() { - flags() - - start := time.Now() - - fmt.Printf("[+] Start time: %s\n", start) - - // Load list of servers - fmt.Println("[+] Loading configuration") - config := loadConfiguration() - fmt.Println() - - // Start Selenium by bringing up the compose file in detatched mode - fmt.Println("[+] Starting Selenium") - RunProgram("docker-compose", []string{"up", "-d"}, 10*time.Second) - - // If any error occurs, bring Selenium down before exiting - defer func() { - fmt.Println("[+] Stopping Selenium") - RunProgram("docker-compose", []string{"down"}, 15*time.Second) - }() - - // Test all provided Jellyfin servers - for _, server := range config.Servers { - if server.Skip { - continue - } - - var configurationDirectory string - var apiKey string - var seleniumArgs []string - - // LSIO containers use some slighly different paths & permissions - lsioImage := strings.Contains(server.Image, "linuxserver") - - fmt.Println() - fmt.Printf("[+] Testing %s\n", server.Comment) - - if server.Docker { - var err error - - // Setup a temporary folder for the container's configuration - configurationDirectory, err = os.MkdirTemp("/dev/shm", "jf-e2e-*") - if err != nil { - panic(err) - } - - // Create a folder to install the plugin into - pluginDirectory := path.Join(configurationDirectory, "plugins", "intro-skipper") - if lsioImage { - pluginDirectory = path.Join(configurationDirectory, "data", "plugins", "intro-skipper") - } - - fmt.Println(" [+] Creating plugin directory") - if err := os.MkdirAll(pluginDirectory, 0700); err != nil { - fmt.Printf(" [!] Failed to create plugin directory: %s\n", err) - goto cleanup - } - - // If this is an LSIO container, adjust the permissions on the plugin directory - if lsioImage { - RunProgram( - "chown", - []string{ - "911:911", - "-R", - path.Join(configurationDirectory, "data", "plugins")}, - 2*time.Second) - } - - // Install the plugin - fmt.Printf(" [+] Copying plugin %s to %s\n", pluginPath, pluginDirectory) - RunProgram("cp", []string{pluginPath, pluginDirectory}, 2*time.Second) - fmt.Println() - - /* Start the container with the following settings: - * Name: jf-e2e - * Port: 8097 - * Media: Mounted to /media, read only - */ - containerArgs := []string{"run", "--name", "jf-e2e", "--rm", "-p", "8097:8096", - "-v", fmt.Sprintf("%s:%s:rw", configurationDirectory, "/config"), - "-v", fmt.Sprintf("%s:%s:ro", config.Common.Library, "/media"), - server.Image} - - fmt.Printf(" [+] Starting container %s\n", server.Image) - go RunProgram("docker", containerArgs, 60*time.Second) - - // Wait for the container to fully start - waitForServerStartup(server.Address) - fmt.Println() - - fmt.Println(" [+] Setting up container") - - // Set up the container - SetupServer(server.Address, containerPassword) - - // Restart the container and wait for it to come back up - RunProgram("docker", []string{"restart", "jf-e2e"}, 10*time.Second) - time.Sleep(time.Second) - waitForServerStartup(server.Address) - fmt.Println() - } else { - fmt.Println("[+] Remote instance, assuming plugin is already installed") - } - - // Get an API key - apiKey = login(server) - - // Rescan the library if this is a server that we just setup - if server.Docker { - fmt.Println(" [+] Rescanning library") - - sendRequest( - server.Address+"/ScheduledTasks/Running/7738148ffcd07979c7ceb148e06b3aed?api_key="+apiKey, - "POST", - "") - - // TODO: poll for task completion - time.Sleep(10 * time.Second) - - fmt.Println() - } - - // Analyze episodes and save report - fmt.Println(" [+] Analyzing episodes") - fmt.Print("\033[37;1m") // change the color of the verifier's text - RunProgram( - "./verifier/verifier", - []string{ - "-address", server.Address, - "-key", apiKey, "-o", - fmt.Sprintf("reports/%s-%d.json", server.Comment, start.Unix())}, - 5*time.Minute) - fmt.Print("\033[39;0m") // reset terminal text color - - // Pause for any manual tests - if server.ManualTests { - fmt.Println(" [!] Pausing for manual tests") - reader := bufio.NewReader(os.Stdin) - reader.ReadString('\n') - } - - // Setup base Selenium arguments - seleniumArgs = []string{ - "-u", // force stdout to be unbuffered - "main.py", - "-host", server.Address, - "-user", server.Username, - "-pass", server.Password, - "-name", config.Common.Episode} - - // Append all requested Selenium tests - seleniumArgs = append(seleniumArgs, "--tests") - seleniumArgs = append(seleniumArgs, server.Tests...) - - // Append all requested browsers - seleniumArgs = append(seleniumArgs, "--browsers") - seleniumArgs = append(seleniumArgs, server.Browsers...) - - // Run Selenium - os.Chdir("selenium") - RunProgram("python3", seleniumArgs, time.Minute) - os.Chdir("..") - - cleanup: - if server.Docker { - // Stop the container - fmt.Println(" [+] Stopping and removing container") - RunProgram("docker", []string{"stop", "jf-e2e"}, 10*time.Second) - - // Cleanup the container's configuration - fmt.Printf(" [+] Deleting %s\n", configurationDirectory) - - if err := os.RemoveAll(configurationDirectory); err != nil { - panic(err) - } - } - } -} - -// Login to the specified Jellyfin server and return an API key -func login(server Server) string { - type AuthenticateUserByName struct { - AccessToken string - } - - fmt.Println(" [+] Sending authentication request") - - // Create request body - rawBody := fmt.Sprintf(`{"Username":"%s","Pw":"%s"}`, server.Username, server.Password) - body := bytes.NewBufferString(rawBody) - - // Create the request - req, err := http.NewRequest( - "POST", - fmt.Sprintf("%s/Users/AuthenticateByName", server.Address), - body) - - if err != nil { - panic(err) - } - - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set( - "X-Emby-Authorization", - `MediaBrowser Client="JF E2E Tests", Version="0.0.1", DeviceId="E2E", Device="E2E"`) - - // Authenticate - res, err := http.DefaultClient.Do(req) - if err != nil { - panic(err) - } else if res.StatusCode != http.StatusOK { - panic(fmt.Sprintf("authentication returned code %d", res.StatusCode)) - } - - defer res.Body.Close() - - // Read body - fullBody, err := io.ReadAll(res.Body) - if err != nil { - panic(err) - } - - // Unmarshal body and return token - var token AuthenticateUserByName - if err := json.Unmarshal(fullBody, &token); err != nil { - panic(err) - } - - return token.AccessToken -} - -// Wait up to ten seconds for the provided Jellyfin server to fully startup -func waitForServerStartup(address string) { - attempts := 10 - fmt.Println(" [+] Waiting for server to finish starting") - - for { - // Sleep in between requests - time.Sleep(time.Second) - - // Ping the /System/Info/Public endpoint - res, err := http.Get(fmt.Sprintf("%s/System/Info/Public", address)) - - // If the server didn't return 200 OK, loop - if err != nil || res.StatusCode != http.StatusOK { - if attempts--; attempts <= 0 { - panic("server is taking too long to startup") - } - - continue - } - - // Assume startup has finished, break - break - } -} - -// Read configuration from config.json -func loadConfiguration() Configuration { - var config Configuration - - // Load the contents of the configuration file - raw, err := os.ReadFile("config.json") - if err != nil { - panic(err) - } - - // Unmarshal - if err := json.Unmarshal(raw, &config); err != nil { - panic(err) - } - - // Print debugging info - fmt.Printf("Library: %s\n", config.Common.Library) - fmt.Printf("Episode: \"%s\"\n", config.Common.Episode) - fmt.Printf("Password: %s\n", containerPassword) - fmt.Println() - - // Check the validity of all entries - for i, server := range config.Servers { - // If this is an entry for a local container, ensure the server address is correct - if server.Image != "" { - // Ensure that values were provided for the host's IP address, base configuration directory, - // and a path to the compiled plugin DLL to install. - if containerAddress == "" { - panic("The -caddr argument is required.") - } - - if pluginPath == "" { - panic("The -dll argument is required.") - } - - server.Username = "admin" - server.Password = containerPassword - server.Address = fmt.Sprintf("http://%s:8097", containerAddress) - server.Docker = true - } - - // If no browsers were specified, default to Chrome (for speed) - if len(server.Browsers) == 0 { - server.Browsers = []string{"chrome"} - } - - // If no tests were specified, only test that the plugin settings page works - if len(server.Tests) == 0 { - server.Tests = []string{"settings"} - } - - // Verify that an address was provided - if len(server.Address) == 0 { - panic("Server address is required") - } - - fmt.Printf("===== Server: %s =====\n", server.Comment) - - if server.Skip { - fmt.Println("Skip: true") - } - - fmt.Printf("Docker: %t\n", server.Docker) - if server.Docker { - fmt.Printf("Image: %s\n", server.Image) - } - - fmt.Printf("Address: %s\n", server.Address) - fmt.Printf("Browsers: %v\n", server.Browsers) - fmt.Printf("Tests: %v\n", server.Tests) - fmt.Println() - - config.Servers[i] = server - } - - fmt.Println("=================") - - return config -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/setup.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/setup.go deleted file mode 100644 index b428c6d..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/setup.go +++ /dev/null @@ -1,79 +0,0 @@ -package main - -import ( - "bytes" - _ "embed" - "fmt" - "net/http" -) - -//go:embed library.json -var librarySetupPayload string - -func SetupServer(server, password string) { - makeUrl := func(u string) string { - return fmt.Sprintf("%s/%s", server, u) - } - - // Set the server language to English - sendRequest( - makeUrl("Startup/Configuration"), - "POST", - `{"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}`) - - // Get the first user - sendRequest(makeUrl("Startup/User"), "GET", "") - - // Create the first user - sendRequest( - makeUrl("Startup/User"), - "POST", - fmt.Sprintf(`{"Name":"admin","Password":"%s"}`, password)) - - // Create a TV library from the media at /media/TV. - sendRequest( - makeUrl("Library/VirtualFolders?collectionType=tvshows&refreshLibrary=false&name=Shows"), - "POST", - librarySetupPayload) - - // Setup remote access - sendRequest( - makeUrl("Startup/RemoteAccess"), - "POST", - `{"EnableRemoteAccess":true,"EnableAutomaticPortMapping":false}`) - - // Mark the wizard as complete - sendRequest( - makeUrl("Startup/Complete"), - "POST", - ``) -} - -func sendRequest(url string, method string, body string) { - // Create the request - req, err := http.NewRequest(method, url, bytes.NewBuffer([]byte(body))) - if err != nil { - panic(err) - } - - // Set required headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set( - "X-Emby-Authorization", - `MediaBrowser Client="JF E2E Tests", Version="0.0.1", DeviceId="E2E", Device="E2E"`) - - // Send it - fmt.Printf(" [+] %s %s", method, url) - res, err := http.DefaultClient.Do(req) - - if err != nil { - fmt.Println() - panic(err) - } - - fmt.Printf(" %d\n", res.StatusCode) - - if res.StatusCode != http.StatusNoContent && res.StatusCode != http.StatusOK { - panic("invalid status code received during setup") - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/structs.go b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/structs.go deleted file mode 100644 index bec93c9..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/e2e_tests/wrapper/structs.go +++ /dev/null @@ -1,26 +0,0 @@ -package main - -type Configuration struct { - Common Common `json:"common"` - Servers []Server `json:"servers"` -} - -type Common struct { - Library string `json:"library"` - Episode string `json:"episode"` -} - -type Server struct { - Skip bool `json:"skip"` - Comment string `json:"comment"` - Address string `json:"address"` - Image string `json:"image"` - Username string `json:"username"` - Password string `json:"password"` - Browsers []string `json:"browsers"` - Tests []string `json:"tests"` - ManualTests bool `json:"manual_tests"` - - // These properties are set at runtime - Docker bool `json:"-"` -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4 b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4 deleted file mode 100644 index c8fa8d2..0000000 Binary files a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4 and /dev/null differ diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4 b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4 deleted file mode 100644 index 8e01cce..0000000 Binary files a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4 and /dev/null differ diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.sln b/ConfusedPolarBear.Plugin.IntroSkipper.sln deleted file mode 100644 index 394a21f..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper.sln +++ /dev/null @@ -1,22 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper", "ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper.Tests", "ConfusedPolarBear.Plugin.IntroSkipper.Tests\ConfusedPolarBear.Plugin.IntroSkipper.Tests.csproj", "{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.Build.0 = Release|Any CPU - {9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9E30DA42-983E-46E0-A3BF-A2BA56FE9718}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs deleted file mode 100644 index 7c720b3..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs +++ /dev/null @@ -1,204 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Threading; -using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; -using Microsoft.Extensions.Logging; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Media file analyzer used to detect end credits that consist of text overlaid on a black background. -/// Bisects the end of the video file to perform an efficient search. -/// -public class BlackFrameAnalyzer : IMediaFileAnalyzer -{ - private readonly TimeSpan _maximumError = new(0, 0, 4); - - private readonly ILogger _logger; - - private int minimumCreditsDuration; - - private int maximumCreditsDuration; - - private int blackFrameMinimumPercentage; - - /// - /// Initializes a new instance of the class. - /// - /// Logger. - public BlackFrameAnalyzer(ILogger logger) - { - var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration(); - minimumCreditsDuration = config.MinimumCreditsDuration; - maximumCreditsDuration = 2 * config.MaximumCreditsDuration; - blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage; - - _logger = logger; - } - - /// - public ReadOnlyCollection AnalyzeMediaFiles( - ReadOnlyCollection analysisQueue, - AnalysisMode mode, - CancellationToken cancellationToken) - { - if (mode != AnalysisMode.Credits) - { - throw new NotImplementedException("mode must equal Credits"); - } - - var creditTimes = new Dictionary(); - - bool isFirstEpisode = true; - - double searchStart = minimumCreditsDuration; - - var searchDistance = 2 * minimumCreditsDuration; - - foreach (var episode in analysisQueue) - { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - // Pre-check to find reasonable starting point. - if (isFirstEpisode) - { - var scanTime = episode.Duration - searchStart; - var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here. - - var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, blackFrameMinimumPercentage); - - while (frames.Length > 0) // While black frames are found increase searchStart - { - searchStart += searchDistance; - - scanTime = episode.Duration - searchStart; - tr = new TimeRange(scanTime - 0.5, scanTime); - - frames = FFmpegWrapper.DetectBlackFrames(episode, tr, blackFrameMinimumPercentage); - - if (searchStart > maximumCreditsDuration) - { - searchStart = maximumCreditsDuration; - break; - } - } - - if (searchStart == minimumCreditsDuration) // Skip if no black frames were found - { - continue; - } - - isFirstEpisode = false; - } - - var intro = AnalyzeMediaFile( - episode, - searchStart, - searchDistance, - blackFrameMinimumPercentage); - - if (intro is null) - { - // If no credits were found, reset the first-episode search logic for the next episode in the sequence. - searchStart = minimumCreditsDuration; - isFirstEpisode = true; - continue; - } - - searchStart = episode.Duration - intro.IntroStart + (0.5 * searchDistance); - - creditTimes[episode.EpisodeId] = intro; - } - - Plugin.Instance!.UpdateTimestamps(creditTimes, mode); - - return analysisQueue - .Where(x => !creditTimes.ContainsKey(x.EpisodeId)) - .ToList() - .AsReadOnly(); - } - - /// - /// Analyzes an individual media file. Only public because of unit tests. - /// - /// Media file to analyze. - /// Search Start Piont. - /// Search Distance. - /// Percentage of the frame that must be black. - /// Credits timestamp. - public Intro? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int searchDistance, int minimum) - { - // Start by analyzing the last N minutes of the file. - var upperLimit = searchStart; - var lowerLimit = Math.Max(searchStart - searchDistance, minimumCreditsDuration); - var start = TimeSpan.FromSeconds(upperLimit); - var end = TimeSpan.FromSeconds(lowerLimit); - var firstFrameTime = 0.0; - - // Continue bisecting the end of the file until the range that contains the first black - // frame is smaller than the maximum permitted error. - while (start - end > _maximumError) - { - // Analyze the middle two seconds from the current bisected range - var midpoint = (start + end) / 2; - var scanTime = episode.Duration - midpoint.TotalSeconds; - var tr = new TimeRange(scanTime, scanTime + 2); - - _logger.LogTrace( - "{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]", - episode.Name, - episode.Duration, - start, - end, - tr.Start, - tr.End); - - var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum); - _logger.LogTrace( - "{Episode} at {Start} has {Count} black frames", - episode.Name, - tr.Start, - frames.Length); - - if (frames.Length == 0) - { - // Since no black frames were found, slide the range closer to the end - start = midpoint - TimeSpan.FromSeconds(2); - - if (midpoint - TimeSpan.FromSeconds(lowerLimit) < _maximumError) - { - lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), minimumCreditsDuration); - - // Reset end for a new search with the increased duration - end = TimeSpan.FromSeconds(lowerLimit); - } - } - else - { - // Some black frames were found, slide the range closer to the start - end = midpoint; - firstFrameTime = frames[0].Time + scanTime; - - if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError) - { - upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), maximumCreditsDuration); - - // Reset start for a new search with the increased duration - start = TimeSpan.FromSeconds(upperLimit); - } - } - } - - if (firstFrameTime > 0) - { - return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration)); - } - - return null; - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs deleted file mode 100644 index d479eeb..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChapterAnalyzer.cs +++ /dev/null @@ -1,245 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Globalization; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading; -using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; -using MediaBrowser.Model.Entities; -using Microsoft.Extensions.Logging; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Chapter name analyzer. -/// -public class ChapterAnalyzer : IMediaFileAnalyzer -{ - private ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// Logger. - public ChapterAnalyzer(ILogger logger) - { - _logger = logger; - } - - /// - public ReadOnlyCollection AnalyzeMediaFiles( - ReadOnlyCollection analysisQueue, - AnalysisMode mode, - CancellationToken cancellationToken) - { - var skippableRanges = new Dictionary(); - - var expression = mode == AnalysisMode.Introduction ? - Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern : - Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern; - - if (string.IsNullOrWhiteSpace(expression)) - { - return analysisQueue; - } - - foreach (var episode in analysisQueue) - { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - var skipRange = FindMatchingChapter( - episode, - new(Plugin.Instance.GetChapters(episode.EpisodeId)), - expression, - mode); - - if (skipRange is null) - { - continue; - } - - skippableRanges.Add(episode.EpisodeId, skipRange); - } - - Plugin.Instance.UpdateTimestamps(skippableRanges, mode); - - return analysisQueue - .Where(x => !skippableRanges.ContainsKey(x.EpisodeId)) - .ToList() - .AsReadOnly(); - } - - /// - /// Searches a list of chapter names for one that matches the provided regular expression. - /// Only public to allow for unit testing. - /// - /// Episode. - /// Media item chapters. - /// Regular expression pattern. - /// Analysis mode. - /// Intro object containing skippable time range, or null if no chapter matched. - public Intro? FindMatchingChapter( - QueuedEpisode episode, - Collection chapters, - string expression, - AnalysisMode mode) - { - Intro? matchingChapter = null; - - var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); - - var minDuration = config.MinimumIntroDuration; - int maxDuration = mode == AnalysisMode.Introduction ? - config.MaximumIntroDuration : - config.MaximumCreditsDuration; - - if (chapters.Count == 0) - { - return null; - } - - if (mode == AnalysisMode.Credits) - { - // Since the ending credits chapter may be the last chapter in the file, append a virtual - // chapter at the very end of the file. - chapters.Add(new() - { - StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks - }); - - // Check all chapters in reverse order, skipping the virtual chapter - for (int i = chapters.Count - 2; i > 0; i--) - { - var current = chapters[i]; - var previous = chapters[i - 1]; - - if (string.IsNullOrWhiteSpace(current.Name)) - { - continue; - } - - var currentRange = new TimeRange( - TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds, - TimeSpan.FromTicks(chapters[i + 1].StartPositionTicks).TotalSeconds); - - var baseMessage = string.Format( - CultureInfo.InvariantCulture, - "{0}: Chapter \"{1}\" ({2} - {3})", - episode.Path, - current.Name, - currentRange.Start, - currentRange.End); - - if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration) - { - _logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage); - continue; - } - - // Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex - // between function invocations. - var match = Regex.IsMatch( - current.Name, - expression, - RegexOptions.None, - TimeSpan.FromSeconds(1)); - - if (!match) - { - _logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage); - continue; - } - - if (!string.IsNullOrWhiteSpace(previous.Name)) - { - // Check for possibility of overlapping keywords - var overlap = Regex.IsMatch( - previous.Name, - expression, - RegexOptions.None, - TimeSpan.FromSeconds(1)); - - if (overlap) - { - continue; - } - } - - matchingChapter = new(episode.EpisodeId, currentRange); - _logger.LogTrace("{Base}: okay", baseMessage); - break; - } - } - else - { - // Check all chapters - for (int i = 0; i < chapters.Count - 1; i++) - { - var current = chapters[i]; - var next = chapters[i + 1]; - - if (string.IsNullOrWhiteSpace(current.Name)) - { - continue; - } - - var currentRange = new TimeRange( - TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds, - TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds); - - var baseMessage = string.Format( - CultureInfo.InvariantCulture, - "{0}: Chapter \"{1}\" ({2} - {3})", - episode.Path, - current.Name, - currentRange.Start, - currentRange.End); - - if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration) - { - _logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage); - continue; - } - - // Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex - // between function invocations. - var match = Regex.IsMatch( - current.Name, - expression, - RegexOptions.None, - TimeSpan.FromSeconds(1)); - - if (!match) - { - _logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage); - continue; - } - - if (!string.IsNullOrWhiteSpace(next.Name)) - { - // Check for possibility of overlapping keywords - var overlap = Regex.IsMatch( - next.Name, - expression, - RegexOptions.None, - TimeSpan.FromSeconds(1)); - - if (overlap) - { - continue; - } - } - - matchingChapter = new(episode.EpisodeId, currentRange); - _logger.LogTrace("{Base}: okay", baseMessage); - break; - } - } - - return matchingChapter; - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs deleted file mode 100644 index 813c89d..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/ChromaprintAnalyzer.cs +++ /dev/null @@ -1,486 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Numerics; -using System.Threading; -using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; -using Microsoft.Extensions.Logging; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Chromaprint audio analyzer. -/// -public class ChromaprintAnalyzer : IMediaFileAnalyzer -{ - /// - /// Seconds of audio in one fingerprint point. - /// This value is defined by the Chromaprint library and should not be changed. - /// - private const double SamplesToSeconds = 0.1238; - - private int minimumIntroDuration; - - private int maximumDifferences; - - private int invertedIndexShift; - - private double maximumTimeSkip; - - private double silenceDetectionMinimumDuration; - - private ILogger _logger; - - private AnalysisMode _analysisMode; - - /// - /// Initializes a new instance of the class. - /// - /// Logger. - public ChromaprintAnalyzer(ILogger logger) - { - var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); - maximumDifferences = config.MaximumFingerprintPointDifferences; - invertedIndexShift = config.InvertedIndexShift; - maximumTimeSkip = config.MaximumTimeSkip; - silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration; - minimumIntroDuration = config.MinimumIntroDuration; - - _logger = logger; - } - - /// - public ReadOnlyCollection AnalyzeMediaFiles( - ReadOnlyCollection analysisQueue, - AnalysisMode mode, - CancellationToken cancellationToken) - { - // All intros for this season. - var seasonIntros = new Dictionary(); - - // Cache of all fingerprints for this season. - var fingerprintCache = new Dictionary(); - - // Episode analysis queue. - var episodeAnalysisQueue = new List(analysisQueue); - - // Episodes that were analyzed and do not have an introduction. - var episodesWithoutIntros = new List(); - - this._analysisMode = mode; - - // Compute fingerprints for all episodes in the season - foreach (var episode in episodeAnalysisQueue) - { - try - { - fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode); - - // Use reversed fingerprints for credits - if (_analysisMode == AnalysisMode.Credits) - { - Array.Reverse(fingerprintCache[episode.EpisodeId]); - } - - if (cancellationToken.IsCancellationRequested) - { - return analysisQueue; - } - } - catch (FingerprintException ex) - { - _logger.LogDebug("Caught fingerprint error: {Ex}", ex); - WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint); - - // Fallback to an empty fingerprint on any error - fingerprintCache[episode.EpisodeId] = Array.Empty(); - } - } - - // While there are still episodes in the queue - while (episodeAnalysisQueue.Count > 0) - { - // Pop the first episode from the queue - var currentEpisode = episodeAnalysisQueue[0]; - episodeAnalysisQueue.RemoveAt(0); - - // Search through all remaining episodes. - foreach (var remainingEpisode in episodeAnalysisQueue) - { - // Compare the current episode to all remaining episodes in the queue. - var (currentIntro, remainingIntro) = CompareEpisodes( - currentEpisode.EpisodeId, - fingerprintCache[currentEpisode.EpisodeId], - remainingEpisode.EpisodeId, - fingerprintCache[remainingEpisode.EpisodeId]); - - // Ignore this comparison result if: - // - one of the intros isn't valid, or - // - the introduction exceeds the configured limit - if ( - !remainingIntro.Valid || - remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration) - { - continue; - } - - /* Since the Fingerprint() function returns an array of Chromaprint points without time - * information, the times reported from the index search function start from 0. - * - * While this is desired behavior for detecting introductions, it breaks credit - * detection, as the audio we're analyzing was extracted from some point into the file. - * - * To fix this, the starting and ending times need to be switched, as they were previously reversed - * and subtracted from the episode duration to get the reported time range. - */ - if (this._analysisMode == AnalysisMode.Credits) - { - // Calculate new values for the current intro - double currentOriginalIntroStart = currentIntro.IntroStart; - currentIntro.IntroStart = currentEpisode.Duration - currentIntro.IntroEnd; - currentIntro.IntroEnd = currentEpisode.Duration - currentOriginalIntroStart; - - // Calculate new values for the remaining intro - double remainingIntroOriginalStart = remainingIntro.IntroStart; - remainingIntro.IntroStart = remainingEpisode.Duration - remainingIntro.IntroEnd; - remainingIntro.IntroEnd = remainingEpisode.Duration - remainingIntroOriginalStart; - } - - // Only save the discovered intro if it is: - // - the first intro discovered for this episode - // - longer than the previously discovered intro - if ( - !seasonIntros.TryGetValue(currentIntro.EpisodeId, out var savedCurrentIntro) || - currentIntro.Duration > savedCurrentIntro.Duration) - { - seasonIntros[currentIntro.EpisodeId] = currentIntro; - } - - if ( - !seasonIntros.TryGetValue(remainingIntro.EpisodeId, out var savedRemainingIntro) || - remainingIntro.Duration > savedRemainingIntro.Duration) - { - seasonIntros[remainingIntro.EpisodeId] = remainingIntro; - } - - break; - } - - // If no intro is found at this point, the popped episode is not reinserted into the queue. - if (!seasonIntros.ContainsKey(currentEpisode.EpisodeId)) - { - episodesWithoutIntros.Add(currentEpisode); - } - } - - // If cancellation was requested, report that no episodes were analyzed. - if (cancellationToken.IsCancellationRequested) - { - return analysisQueue; - } - - if (this._analysisMode == AnalysisMode.Introduction) - { - // Adjust all introduction end times so that they end at silence. - seasonIntros = AdjustIntroEndTimes(analysisQueue, seasonIntros); - } - - Plugin.Instance!.UpdateTimestamps(seasonIntros, this._analysisMode); - - return episodesWithoutIntros.AsReadOnly(); - } - - /// - /// Analyze two episodes to find an introduction sequence shared between them. - /// - /// First episode id. - /// First episode fingerprint points. - /// Second episode id. - /// Second episode fingerprint points. - /// Intros for the first and second episodes. - public (Intro Lhs, Intro Rhs) CompareEpisodes( - Guid lhsId, - uint[] lhsPoints, - Guid rhsId, - uint[] rhsPoints) - { - // Creates an inverted fingerprint point index for both episodes. - // For every point which is a 100% match, search for an introduction at that point. - var (lhsRanges, rhsRanges) = SearchInvertedIndex(lhsId, lhsPoints, rhsId, rhsPoints); - - if (lhsRanges.Count > 0) - { - _logger.LogTrace("Index search successful"); - - return GetLongestTimeRange(lhsId, lhsRanges, rhsId, rhsRanges); - } - - _logger.LogTrace( - "Unable to find a shared introduction sequence between {LHS} and {RHS}", - lhsId, - rhsId); - - return (new Intro(lhsId), new Intro(rhsId)); - } - - /// - /// Locates the longest range of similar audio and returns an Intro class for each range. - /// - /// First episode id. - /// First episode shared timecodes. - /// Second episode id. - /// Second episode shared timecodes. - /// Intros for the first and second episodes. - private (Intro Lhs, Intro Rhs) GetLongestTimeRange( - Guid lhsId, - List lhsRanges, - Guid rhsId, - List rhsRanges) - { - // Store the longest time range as the introduction. - lhsRanges.Sort(); - rhsRanges.Sort(); - - var lhsIntro = lhsRanges[0]; - var rhsIntro = rhsRanges[0]; - - // If the intro starts early in the episode, move it to the beginning. - if (lhsIntro.Start <= 5) - { - lhsIntro.Start = 0; - } - - if (rhsIntro.Start <= 5) - { - rhsIntro.Start = 0; - } - - // Create Intro classes for each time range. - return (new Intro(lhsId, lhsIntro), new Intro(rhsId, rhsIntro)); - } - - /// - /// Search for a shared introduction sequence using inverted indexes. - /// - /// LHS ID. - /// Left episode fingerprint points. - /// RHS ID. - /// Right episode fingerprint points. - /// List of shared TimeRanges between the left and right episodes. - private (List Lhs, List Rhs) SearchInvertedIndex( - Guid lhsId, - uint[] lhsPoints, - Guid rhsId, - uint[] rhsPoints) - { - var lhsRanges = new List(); - var rhsRanges = new List(); - - // Generate inverted indexes for the left and right episodes. - var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, this._analysisMode); - var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, this._analysisMode); - var indexShifts = new HashSet(); - - // For all audio points in the left episode, check if the right episode has a point which matches exactly. - // If an exact match is found, calculate the shift that must be used to align the points. - foreach (var kvp in lhsIndex) - { - var originalPoint = kvp.Key; - - for (var i = -1 * invertedIndexShift; i <= invertedIndexShift; i++) - { - var modifiedPoint = (uint)(originalPoint + i); - - if (rhsIndex.TryGetValue(modifiedPoint, out var rhsModifiedPoint)) - { - var lhsFirst = lhsIndex[originalPoint]; - var rhsFirst = rhsModifiedPoint; - indexShifts.Add(rhsFirst - lhsFirst); - } - } - } - - // Use all discovered shifts to compare the episodes. - foreach (var shift in indexShifts) - { - var (lhsIndexContiguous, rhsIndexContiguous) = FindContiguous(lhsPoints, rhsPoints, shift); - if (lhsIndexContiguous.End > 0 && rhsIndexContiguous.End > 0) - { - lhsRanges.Add(lhsIndexContiguous); - rhsRanges.Add(rhsIndexContiguous); - } - } - - return (lhsRanges, rhsRanges); - } - - /// - /// Finds the longest contiguous region of similar audio between two fingerprints using the provided shift amount. - /// - /// First fingerprint to compare. - /// Second fingerprint to compare. - /// Amount to shift one fingerprint by. - private (TimeRange Lhs, TimeRange Rhs) FindContiguous( - uint[] lhs, - uint[] rhs, - int shiftAmount) - { - var leftOffset = 0; - var rightOffset = 0; - - // Calculate the offsets for the left and right hand sides. - if (shiftAmount < 0) - { - leftOffset -= shiftAmount; - } - else - { - rightOffset += shiftAmount; - } - - // Store similar times for both LHS and RHS. - var lhsTimes = new List(); - var rhsTimes = new List(); - var upperLimit = Math.Min(lhs.Length, rhs.Length) - Math.Abs(shiftAmount); - - // XOR all elements in LHS and RHS, using the shift amount from above. - for (var i = 0; i < upperLimit; i++) - { - // XOR both samples at the current position. - var lhsPosition = i + leftOffset; - var rhsPosition = i + rightOffset; - var diff = lhs[lhsPosition] ^ rhs[rhsPosition]; - - // If the difference between the samples is small, flag both times as similar. - if (CountBits(diff) > maximumDifferences) - { - continue; - } - - var lhsTime = lhsPosition * SamplesToSeconds; - var rhsTime = rhsPosition * SamplesToSeconds; - - lhsTimes.Add(lhsTime); - rhsTimes.Add(rhsTime); - } - - // Ensure the last timestamp is checked - lhsTimes.Add(double.MaxValue); - rhsTimes.Add(double.MaxValue); - - // Now that both fingerprints have been compared at this shift, see if there's a contiguous time range. - var lContiguous = TimeRangeHelpers.FindContiguous(lhsTimes.ToArray(), maximumTimeSkip); - if (lContiguous is null || lContiguous.Duration < minimumIntroDuration) - { - return (new TimeRange(), new TimeRange()); - } - - // Since LHS had a contiguous time range, RHS must have one also. - var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), maximumTimeSkip)!; - - if (this._analysisMode == AnalysisMode.Introduction) - { - // Tweak the end timestamps just a bit to ensure as little content as possible is skipped over. - // TODO: remove this - if (lContiguous.Duration >= 90) - { - lContiguous.End -= 2 * maximumTimeSkip; - rContiguous.End -= 2 * maximumTimeSkip; - } - else if (lContiguous.Duration >= 30) - { - lContiguous.End -= maximumTimeSkip; - rContiguous.End -= maximumTimeSkip; - } - } - - return (lContiguous, rContiguous); - } - - /// - /// Adjusts the end timestamps of all intros so that they end at silence. - /// - /// QueuedEpisodes to adjust. - /// Original introductions. - private Dictionary AdjustIntroEndTimes( - ReadOnlyCollection episodes, - Dictionary originalIntros) - { - Dictionary modifiedIntros = new(); - - // For all episodes - foreach (var episode in episodes) - { - _logger.LogTrace( - "Adjusting introduction end time for {Name} ({Id})", - episode.Name, - episode.EpisodeId); - - // If no intro was found for this episode, skip it. - if (!originalIntros.TryGetValue(episode.EpisodeId, out var originalIntro)) - { - _logger.LogTrace("{Name} does not have an intro", episode.Name); - continue; - } - - // Only adjust the end timestamp of the intro - var originalIntroEnd = new TimeRange(originalIntro.IntroEnd - 15, originalIntro.IntroEnd); - - _logger.LogTrace( - "{Name} original intro: {Start} - {End}", - episode.Name, - originalIntro.IntroStart, - originalIntro.IntroEnd); - - // Detect silence in the media file up to the end of the intro. - var silence = FFmpegWrapper.DetectSilence(episode, (int)originalIntro.IntroEnd + 2); - - // For all periods of silence - foreach (var currentRange in silence) - { - _logger.LogTrace( - "{Name} silence: {Start} - {End}", - episode.Name, - currentRange.Start, - currentRange.End); - - // Ignore any silence that: - // * doesn't intersect the ending of the intro, or - // * is shorter than the user defined minimum duration, or - // * starts before the introduction does - if ( - !originalIntroEnd.Intersects(currentRange) || - currentRange.Duration < silenceDetectionMinimumDuration || - currentRange.Start < originalIntro.IntroStart) - { - continue; - } - - // Adjust the end timestamp of the intro to match the start of the silence region. - originalIntro.IntroEnd = currentRange.Start; - break; - } - - _logger.LogTrace( - "{Name} adjusted intro: {Start} - {End}", - episode.Name, - originalIntro.IntroStart, - originalIntro.IntroEnd); - - // Add the (potentially) modified intro back. - modifiedIntros[episode.EpisodeId] = originalIntro; - } - - return modifiedIntros; - } - - /// - /// Count the number of bits that are set in the provided number. - /// - /// Number to count bits in. - /// Number of bits that are equal to 1. - public int CountBits(uint number) - { - return BitOperations.PopCount(number); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/IMediaFileAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/IMediaFileAnalyzer.cs deleted file mode 100644 index 047aba7..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/IMediaFileAnalyzer.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.ObjectModel; -using System.Threading; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Media file analyzer interface. -/// -public interface IMediaFileAnalyzer -{ - /// - /// Analyze media files for shared introductions or credits, returning all media files that were **not successfully analyzed**. - /// - /// Collection of unanalyzed media files. - /// Analysis mode. - /// Cancellation token from scheduled task. - /// Collection of media files that were **unsuccessfully analyzed**. - public ReadOnlyCollection AnalyzeMediaFiles( - ReadOnlyCollection analysisQueue, - AnalysisMode mode, - CancellationToken cancellationToken); -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/SegmentAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/SegmentAnalyzer.cs deleted file mode 100644 index 873fc7c..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/SegmentAnalyzer.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Collections.ObjectModel; -using System.Threading; -using Microsoft.Extensions.Logging; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Chapter name analyzer. -/// -public class SegmentAnalyzer : IMediaFileAnalyzer -{ - private ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// Logger. - public SegmentAnalyzer(ILogger logger) - { - _logger = logger; - } - - /// - public ReadOnlyCollection AnalyzeMediaFiles( - ReadOnlyCollection analysisQueue, - AnalysisMode mode, - CancellationToken cancellationToken) - { - return analysisQueue; - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs deleted file mode 100644 index fc7dff6..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkip.cs +++ /dev/null @@ -1,236 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using System.Timers; -using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Plugins; -using MediaBrowser.Model.Session; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Timer = System.Timers.Timer; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Automatically skip past introduction sequences. -/// Commands clients to seek to the end of the intro as soon as they start playing it. -/// -public class AutoSkip : IHostedService, IDisposable -{ - private readonly object _sentSeekCommandLock = new(); - - private ILogger _logger; - private IUserDataManager _userDataManager; - private ISessionManager _sessionManager; - private Timer _playbackTimer = new(1000); - private Dictionary _sentSeekCommand; - - /// - /// Initializes a new instance of the class. - /// - /// User data manager. - /// Session manager. - /// Logger. - public AutoSkip( - IUserDataManager userDataManager, - ISessionManager sessionManager, - ILogger logger) - { - _userDataManager = userDataManager; - _sessionManager = sessionManager; - _logger = logger; - _sentSeekCommand = new Dictionary(); - } - - private void AutoSkipChanged(object? sender, BasePluginConfiguration e) - { - var configuration = (PluginConfiguration)e; - var newState = configuration.AutoSkip; - _logger.LogDebug("Setting playback timer enabled to {NewState}", newState); - _playbackTimer.Enabled = newState; - } - - private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) - { - var itemId = e.Item.Id; - var newState = false; - var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); - - // Ignore all events except playback start & end - if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) - { - return; - } - - // Lookup the session for this item. - SessionInfo? session = null; - - try - { - foreach (var needle in _sessionManager.Sessions) - { - if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) - { - session = needle; - break; - } - } - - if (session == null) - { - _logger.LogInformation("Unable to find session for {Item}", itemId); - return; - } - } - catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) - { - return; - } - - // If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session. - if (!Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) - { - newState = true; - } - - // Reset the seek command state for this device. - lock (_sentSeekCommandLock) - { - var device = session.DeviceId; - - _logger.LogDebug("Resetting seek command state for session {Session}", device); - _sentSeekCommand[device] = newState; - } - } - - private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) - { - foreach (var session in _sessionManager.Sessions) - { - var deviceId = session.DeviceId; - var itemId = session.NowPlayingItem.Id; - var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond; - - // Don't send the seek command more than once in the same session. - lock (_sentSeekCommandLock) - { - if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) - { - _logger.LogTrace("Already sent seek command for session {Session}", deviceId); - continue; - } - } - - // Assert that an intro was detected for this item. - if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid) - { - continue; - } - - // Seek is unreliable if called at the very start of an episode. - var adjustedStart = Math.Max(5, intro.IntroStart); - - _logger.LogTrace( - "Playback position is {Position}, intro runs from {Start} to {End}", - position, - adjustedStart, - intro.IntroEnd); - - if (position < adjustedStart || position > intro.IntroEnd) - { - continue; - } - - // Notify the user that an introduction is being skipped for them. - var notificationText = Plugin.Instance.Configuration.AutoSkipNotificationText; - if (!string.IsNullOrWhiteSpace(notificationText)) - { - _sessionManager.SendMessageCommand( - session.Id, - session.Id, - new MessageCommand - { - Header = string.Empty, // some clients require header to be a string instead of null - Text = notificationText, - TimeoutMs = 2000, - }, - CancellationToken.None); - } - - _logger.LogDebug("Sending seek command to {Session}", deviceId); - - var introEnd = (long)intro.IntroEnd - Plugin.Instance.Configuration.SecondsOfIntroToPlay; - - _sessionManager.SendPlaystateCommand( - session.Id, - session.Id, - new PlaystateRequest - { - Command = PlaystateCommand.Seek, - ControllingUserId = session.UserId.ToString(), - SeekPositionTicks = introEnd * TimeSpan.TicksPerSecond, - }, - CancellationToken.None); - - // Flag that we've sent the seek command so that it's not sent repeatedly - lock (_sentSeekCommandLock) - { - _logger.LogTrace("Setting seek command state for session {Session}", deviceId); - _sentSeekCommand[deviceId] = true; - } - } - } - - /// - /// Dispose. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Protected dispose. - /// - /// Dispose. - protected virtual void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - _playbackTimer.Stop(); - _playbackTimer.Dispose(); - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogDebug("Setting up automatic skipping"); - - _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; - Plugin.Instance!.ConfigurationChanged += AutoSkipChanged; - - // Make the timer restart automatically and set enabled to match the configuration value. - _playbackTimer.AutoReset = true; - _playbackTimer.Elapsed += PlaybackTimer_Elapsed; - - AutoSkipChanged(null, Plugin.Instance.Configuration); - - return Task.CompletedTask; - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; - return Task.CompletedTask; - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs b/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs deleted file mode 100644 index 07155e3..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/AutoSkipCredits.cs +++ /dev/null @@ -1,236 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using System.Timers; -using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Plugins; -using MediaBrowser.Model.Session; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Timer = System.Timers.Timer; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Automatically skip past credit sequences. -/// Commands clients to seek to the end of the credits as soon as they start playing it. -/// -public class AutoSkipCredits : IHostedService, IDisposable -{ - private readonly object _sentSeekCommandLock = new(); - - private ILogger _logger; - private IUserDataManager _userDataManager; - private ISessionManager _sessionManager; - private Timer _playbackTimer = new(1000); - private Dictionary _sentSeekCommand; - - /// - /// Initializes a new instance of the class. - /// - /// User data manager. - /// Session manager. - /// Logger. - public AutoSkipCredits( - IUserDataManager userDataManager, - ISessionManager sessionManager, - ILogger logger) - { - _userDataManager = userDataManager; - _sessionManager = sessionManager; - _logger = logger; - _sentSeekCommand = new Dictionary(); - } - - private void AutoSkipCreditChanged(object? sender, BasePluginConfiguration e) - { - var configuration = (PluginConfiguration)e; - var newState = configuration.AutoSkipCredits; - _logger.LogDebug("Setting playback timer enabled to {NewState}", newState); - _playbackTimer.Enabled = newState; - } - - private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e) - { - var itemId = e.Item.Id; - var newState = false; - var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1); - - // Ignore all events except playback start & end - if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished) - { - return; - } - - // Lookup the session for this item. - SessionInfo? session = null; - - try - { - foreach (var needle in _sessionManager.Sessions) - { - if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId) - { - session = needle; - break; - } - } - - if (session == null) - { - _logger.LogInformation("Unable to find session for {Item}", itemId); - return; - } - } - catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException) - { - return; - } - - // If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session. - if (!Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1) - { - newState = true; - } - - // Reset the seek command state for this device. - lock (_sentSeekCommandLock) - { - var device = session.DeviceId; - - _logger.LogDebug("Resetting seek command state for session {Session}", device); - _sentSeekCommand[device] = newState; - } - } - - private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e) - { - foreach (var session in _sessionManager.Sessions) - { - var deviceId = session.DeviceId; - var itemId = session.NowPlayingItem.Id; - var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond; - - // Don't send the seek command more than once in the same session. - lock (_sentSeekCommandLock) - { - if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent) - { - _logger.LogTrace("Already sent seek command for session {Session}", deviceId); - continue; - } - } - - // Assert that credits were detected for this item. - if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var credit) || !credit.Valid) - { - continue; - } - - // Seek is unreliable if called at the very start of an episode. - var adjustedStart = Math.Max(5, credit.IntroStart); - - _logger.LogTrace( - "Playback position is {Position}, credits run from {Start} to {End}", - position, - adjustedStart, - credit.IntroEnd); - - if (position < adjustedStart || position > credit.IntroEnd) - { - continue; - } - - // Notify the user that credits are being skipped for them. - var notificationText = Plugin.Instance!.Configuration.AutoSkipCreditsNotificationText; - if (!string.IsNullOrWhiteSpace(notificationText)) - { - _sessionManager.SendMessageCommand( - session.Id, - session.Id, - new MessageCommand - { - Header = string.Empty, // some clients require header to be a string instead of null - Text = notificationText, - TimeoutMs = 2000, - }, - CancellationToken.None); - } - - _logger.LogDebug("Sending seek command to {Session}", deviceId); - - var creditEnd = (long)credit.IntroEnd; - - _sessionManager.SendPlaystateCommand( - session.Id, - session.Id, - new PlaystateRequest - { - Command = PlaystateCommand.Seek, - ControllingUserId = session.UserId.ToString(), - SeekPositionTicks = creditEnd * TimeSpan.TicksPerSecond, - }, - CancellationToken.None); - - // Flag that we've sent the seek command so that it's not sent repeatedly - lock (_sentSeekCommandLock) - { - _logger.LogTrace("Setting seek command state for session {Session}", deviceId); - _sentSeekCommand[deviceId] = true; - } - } - } - - /// - /// Dispose. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Protected dispose. - /// - /// Dispose. - protected virtual void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - _playbackTimer.Stop(); - _playbackTimer.Dispose(); - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogDebug("Setting up automatic credit skipping"); - - _userDataManager.UserDataSaved += UserDataManager_UserDataSaved; - Plugin.Instance!.ConfigurationChanged += AutoSkipCreditChanged; - - // Make the timer restart automatically and set enabled to match the configuration value. - _playbackTimer.AutoReset = true; - _playbackTimer.Elapsed += PlaybackTimer_Elapsed; - - AutoSkipCreditChanged(null, Plugin.Instance.Configuration); - - return Task.CompletedTask; - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _userDataManager.UserDataSaved -= UserDataManager_UserDataSaved; - return Task.CompletedTask; - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs deleted file mode 100644 index 43fd7b0..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics; -using MediaBrowser.Model.Plugins; - -namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration; - -/// -/// Plugin configuration. -/// -public class PluginConfiguration : BasePluginConfiguration -{ - /// - /// Initializes a new instance of the class. - /// - public PluginConfiguration() - { - } - - // ===== Analysis settings ===== - - /// - /// Gets or sets the max degree of parallelism used when analyzing episodes. - /// - public int MaxParallelism { get; set; } = 2; - - /// - /// Gets or sets the comma separated list of library names to analyze. If empty, all libraries will be analyzed. - /// - public string SelectedLibraries { get; set; } = string.Empty; - - /// - /// Gets a temporary limitation on file paths to be analyzed. Should be empty when automatic scan is idle. - /// - public IList PathRestrictions { get; } = new List(); - - /// - /// Gets or sets a value indicating whether to scan for intros during a scheduled task. - /// - public bool AutoDetectIntros { get; set; } = false; - - /// - /// Gets or sets a value indicating whether to scan for credits during a scheduled task. - /// - public bool AutoDetectCredits { get; set; } = false; - - /// - /// Gets or sets a value indicating whether to analyze season 0. - /// - public bool AnalyzeSeasonZero { get; set; } = false; - - /// - /// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem. - /// - public bool CacheFingerprints { get; set; } = true; - - /// - /// Gets or sets a value indicating whether analysis will use Chromaprint to determine fingerprints. - /// - public bool UseChromaprint { get; set; } = true; - - // ===== EDL handling ===== - - /// - /// Gets or sets a value indicating the action to write to created EDL files. - /// - public EdlAction EdlAction { get; set; } = EdlAction.None; - - /// - /// Gets or sets a value indicating whether to regenerate all EDL files during the next scan. - /// By default, EDL files are only written for a season if the season had at least one newly analyzed episode. - /// If this is set, all EDL files will be regenerated and overwrite any existing EDL file. - /// - public bool RegenerateEdlFiles { get; set; } = false; - - // ===== Custom analysis settings ===== - - /// - /// Gets or sets the percentage of each episode's audio track to analyze. - /// - public int AnalysisPercent { get; set; } = 25; - - /// - /// Gets or sets the upper limit (in minutes) on the length of each episode's audio track that will be analyzed. - /// - public int AnalysisLengthLimit { get; set; } = 10; - - /// - /// Gets or sets the minimum length of similar audio that will be considered an introduction. - /// - public int MinimumIntroDuration { get; set; } = 15; - - /// - /// Gets or sets the maximum length of similar audio that will be considered an introduction. - /// - public int MaximumIntroDuration { get; set; } = 120; - - /// - /// Gets or sets the minimum length of similar audio that will be considered ending credits. - /// - public int MinimumCreditsDuration { get; set; } = 15; - - /// - /// 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. - /// - public int MaximumCreditsDuration { get; set; } = 300; - - /// - /// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame. - /// - public int BlackFrameMinimumPercentage { get; set; } = 85; - - /// - /// Gets or sets the regular expression used to detect introduction chapters. - /// - public string ChapterAnalyzerIntroductionPattern { get; set; } = - @"(^|\s)(Intro|Introduction|OP|Opening)(\s|$)"; - - /// - /// Gets or sets the regular expression used to detect ending credit chapters. - /// - public string ChapterAnalyzerEndCreditsPattern { get; set; } = - @"(^|\s)(Credits?|ED|Ending)(\s|$)"; - - // ===== Playback settings ===== - - /// - /// Gets or sets a value indicating whether to show the skip intro button. - /// - public bool SkipButtonVisible { get; set; } = true; - - /// - /// Gets or sets a value indicating whether introductions should be automatically skipped. - /// - public bool AutoSkip { get; set; } - - /// - /// Gets or sets a value indicating whether credits should be automatically skipped. - /// - public bool AutoSkipCredits { get; set; } - - /// - /// Gets or sets the seconds before the intro starts to show the skip prompt at. - /// - public int ShowPromptAdjustment { get; set; } = 5; - - /// - /// Gets or sets the seconds after the intro starts to hide the skip prompt at. - /// - public int HidePromptAdjustment { get; set; } = 10; - - /// - /// Gets or sets a value indicating whether the introduction in the first episode of a season should be ignored. - /// - public bool SkipFirstEpisode { get; set; } = true; - - /// - /// Gets or sets a value indicating whether the skip button should be displayed for the duration of the intro. - /// - public bool PersistSkipButton { get; set; } = true; - - /// - /// Gets or sets the amount of intro to play (in seconds). - /// - public int SecondsOfIntroToPlay { get; set; } = 2; - - // ===== Internal algorithm settings ===== - - /// - /// Gets or sets the maximum number of bits (out of 32 total) that can be different between two Chromaprint points before they are considered dissimilar. - /// Defaults to 6 (81% similar). - /// - public int MaximumFingerprintPointDifferences { get; set; } = 6; - - /// - /// Gets or sets the maximum number of seconds that can pass between two similar fingerprint points before a new time range is started. - /// - public double MaximumTimeSkip { get; set; } = 3.5; - - /// - /// Gets or sets the amount to shift inverted indexes by. - /// - public int InvertedIndexShift { get; set; } = 2; - - /// - /// Gets or sets the maximum amount of noise (in dB) that is considered silent. - /// Lowering this number will increase the filter's sensitivity to noise. - /// - public int SilenceDetectionMaximumNoise { get; set; } = -50; - - /// - /// Gets or sets the minimum duration of audio (in seconds) that is considered silent. - /// - public double SilenceDetectionMinimumDuration { get; set; } = 0.33; - - // ===== Localization support ===== - - /// - /// Gets or sets the text to display in the skip button in introduction mode. - /// - public string SkipButtonIntroText { get; set; } = "Skip Intro"; - - /// - /// Gets or sets the text to display in the skip button in end credits mode. - /// - public string SkipButtonEndCreditsText { get; set; } = "Next"; - - /// - /// Gets or sets the notification text sent after automatically skipping an introduction. - /// - public string AutoSkipNotificationText { get; set; } = "Intro skipped"; - - /// - /// Gets or sets the notification text sent after automatically skipping credits. - /// - public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped"; - - /// - /// Gets or sets the number of threads for an ffmpeg process. - /// - public int ProcessThreads { get; set; } = 0; - - /// - /// Gets or sets the relative priority for an ffmpeg process. - /// - public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal; -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/UserInterfaceConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/UserInterfaceConfiguration.cs deleted file mode 100644 index 05ee453..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/UserInterfaceConfiguration.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration; - -/// -/// User interface configuration. -/// -public class UserInterfaceConfiguration -{ - /// - /// Initializes a new instance of the class. - /// - /// Skip button visibility. - /// Skip button intro text. - /// Skip button end credits text. - public UserInterfaceConfiguration(bool visible, string introText, string creditsText) - { - SkipButtonVisible = visible; - SkipButtonIntroText = introText; - SkipButtonEndCreditsText = creditsText; - } - - /// - /// Gets or sets a value indicating whether to show the skip intro button. - /// - public bool SkipButtonVisible { get; set; } - - /// - /// Gets or sets the text to display in the skip intro button in introduction mode. - /// - public string SkipButtonIntroText { get; set; } - - /// - /// Gets or sets the text to display in the skip intro button in end credits mode. - /// - public string SkipButtonEndCreditsText { get; set; } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html deleted file mode 100644 index 8e03f1a..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html +++ /dev/null @@ -1,1226 +0,0 @@ - - - - - - - - -
-
- -
-
-
- Analysis - -
- - -
- If enabled, introductions will be automatically analyzed for new media -
-
- -
- - -
- If enabled, credits will be automatically analyzed for new media -
-
- Note: Not selecting at least one automatic detection type will disable automatic scans. To configure the scheduled task, see scheduled tasks. -
-
- -
- - -
- If checked, season 0 (specials / extras) will be included in analysis. -
-
- Note: Shows containing both a specials and extra folder will identify extras as season 0 - and ignore specials, regardless of this setting. -
-
- -
- - -
- Maximum number of simultaneous async episode analysis operations. -
-
- -
- - -
- Enter the names of libraries to analyze, separated by commas. If this field is left - blank, all libraries on the server containing television episodes will be analyzed. -
-
- -
- Modify Segment Parameters - -
-
- - -
- Analysis will be limited to this percentage of each episode's audio. For example, a - value of 25 (the default) will limit analysis to the first quarter of each episode. -
-
- -
- - -
- Analysis will be limited to this amount of each episode's audio. For example, a - value of 10 (the default) will limit analysis to the first 10 minutes of each - episode. -
-
- -
- - -
- Similar sounding audio which is shorter than this duration will not be considered an - introduction. -
-
- -
- - -
- Similar sounding audio which is longer than this duration will not be considered an - introduction. -
-
- -
- - -
- Similar sounding audio which is shorter than this duration will not be considered credits. -
-
- -
- - -
- Similar sounding audio which is longer than this duration will not be considered credits. -
-
- -

- The amount of each episode's audio that will be analyzed is determined using both - the percentage of audio and maximum runtime of audio to analyze. The minimum of - (episode duration * percent, maximum runtime) is the amount of audio that will - be analyzed. -

- -

- If the audio percentage or maximum runtime settings are modified, the cached - fingerprints and introduction timestamps for each season you want to analyze with the - modified settings will have to be deleted. - - Increasing either of these settings will cause episode analysis to take much longer. -

-
- -
- EDL File Generation - -
-
- - - -
- If set to a value other than None, specifies which action to write to - MPlayer compatible EDL files - alongside your episode files.
- - If this value is changed after EDL files are generated, you must check the - "Regenerate EDL files" checkbox below. -
-
- -
- - -
- If checked, the plugin will overwrite all EDL files associated with - your episodes with the currently discovered introduction/credit timestamps and EDL action. -
-
-
- -
- Silence Detection Options - -
-
- - -
- Noise tolerance in negative decibels. -
-
- -
- - -
- Minimum silence duration in seconds before adjusting introduction end time. -
-
-
- -
- Process Configuration - -
-
- - -
- If checked, analysis will use Chromaprint to compare episode audio and identify intros. -
- WARNING: Disabling this option may result in incomplete or innaccurate analysis! -
-
-
- -
- - -
- If checked, episode fingerprints will be saved on the filesystem to improve analysis speed. -
- WARNING: Disabling the cache will cause all libraries to be re-scanned, which can take a very long time! -
-
-
- -
- - - -
- Sets the relative priority of the analysis ffmpeg process to other parallel operations - (ie. transcoding, chapter detection, etc). -
-
- -
- - -
- Number of simultaneous processes to use for ffmpeg operations. -
- This value is most often defined as 1 thread per CPU core, - but setting a value of 0 (default) will use the maximum threads available. -
-
-
-
- -
- Playback - -
- - -
- If checked, intros will be automatically skipped. If you access Jellyfin through a - reverse proxy, it must be configured to proxy web - sockets.
-
-
- -
- - -
- If checked, auto skip will play the introduction of the first episode in a season.
-
-
-
- -
- - -
- If checked, credits will be automatically skipped. If you access Jellyfin through a - reverse proxy, it must be configured to proxy web - sockets.
-
-
- -
- - -
- If checked, a skip button will be displayed at the start of an episode's introduction. - Only applies to the web interface and compatible applications. -
-
-
- -
- - -
- If checked, skip button will remain visible throught the intro (offset and timeout are ignored). -
- Note: If unchecked, button will only appear in the player controls after the set timeout. -
-
- -
- - -
- Seconds to display skip prompt before introduction begins. -
-
-
- -
- - -
- Seconds after introduction before skip prompt is hidden. -
-
-
- -
- - -
- Seconds of introduction ending that should be played. Defaults to 2. -
-
- -
- User Interface Customization - -
-
- - -
- Text to display in the skip intro button. -
-
- -
- - -
- Text to display in the skip end credits button. -
-
- -
- - -
- Message shown after automatically skipping an introduction. Leave blank to disable notification. -
-
- -
- - -
- Message shown after automatically skipping credits. Leave blank to disable notification. -
-
-
-
- -
- -
-
-
- -
- Advanced - -
- Support Bundle Info - - -
- -
- Manage Fingerprints - -
-

Select episodes to manage

-
- - -
- - - -
-
- - - - - -

Fingerprint Visualizer

-

- Interactively compare the audio fingerprints of two episodes.
- The blue and red bar to the right of the fingerprint diff turns blue - when the corresponding fingerprint points are at least 80% similar. -

- - - - - - - - - - - - - - - - - - - - - - - -
KeyFunction
Up arrow - Shift the left episode up by 0.1238 seconds. - Holding control will shift the episode by 10 seconds. -
Down arrow - Shift the left episode down by 0.1238 seconds. - Holding control will shift the episode by 10 seconds. -
Right arrowAdvance to the next pair of episodes.
Left arrowGo back to the previous pair of episodes.
-
- - Shift amount: - -
- Suggested shifts: -
-
- - - -
- -
-
-
- - -
- - -
- - -
- - - -
- - -
-
-
-
- - - - -
- - - diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js deleted file mode 100644 index d794c3c..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js +++ /dev/null @@ -1,289 +0,0 @@ -let introSkipper = { - allowEnter: true, - skipSegments: {}, - videoPlayer: {}, - // .bind() is used here to prevent illegal invocation errors - originalFetch: window.fetch.bind(window), -}; -introSkipper.d = function (msg) { - console.debug("[intro skipper] ", msg); - } - /** Setup event listeners */ - introSkipper.setup = function () { - document.addEventListener("viewshow", introSkipper.viewShow); - window.fetch = introSkipper.fetchWrapper; - introSkipper.d("Registered hooks"); - } - /** Wrapper around fetch() that retrieves skip segments for the currently playing item. */ - introSkipper.fetchWrapper = async function (...args) { - // Based on JellyScrub's trickplay.js - let [resource, options] = args; - let response = await introSkipper.originalFetch(resource, options); - // Bail early if this isn't a playback info URL - try { - let path = new URL(resource).pathname; - if (!path.includes("/PlaybackInfo")) { return response; } - introSkipper.d("Retrieving skip segments from URL"); - introSkipper.d(path); - - // Check for context root and set id accordingly - let path_arr = path.split("/"); - let id = ""; - if (path_arr[1] == "Items") { - id = path_arr[2]; - } else { - id = path_arr[3]; - } - - introSkipper.skipSegments = await introSkipper.secureFetch(`Episode/${id}/IntroSkipperSegments`); - introSkipper.d("Successfully retrieved skip segments"); - introSkipper.d(introSkipper.skipSegments); - } - catch (e) { - console.error("Unable to get skip segments from", resource, e); - } - return response; - } - /** - * Event handler that runs whenever the current view changes. - * Used to detect the start of video playback. - */ - introSkipper.viewShow = function () { - const location = window.location.hash; - introSkipper.d("Location changed to " + location); - if (location !== "#/video") { - introSkipper.d("Ignoring location change"); - return; - } - introSkipper.injectCss(); - introSkipper.injectButton(); - introSkipper.videoPlayer = document.querySelector("video"); - if (introSkipper.videoPlayer != null) { - introSkipper.d("Hooking video timeupdate"); - introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged); - document.body.addEventListener('keydown', introSkipper.eventHandler, true); - } - } - /** - * Injects the CSS used by the skip intro button. - * Calling this function is a no-op if the CSS has already been injected. - */ - introSkipper.injectCss = function () { - if (introSkipper.testElement("style#introSkipperCss")) { - introSkipper.d("CSS already added"); - return; - } - introSkipper.d("Adding CSS"); - let styleElement = document.createElement("style"); - styleElement.id = "introSkipperCss"; - styleElement.innerText = ` - :root { - --rounding: .2em; - --accent: 0, 164, 220; - } - #skipIntro.upNextContainer { - width: unset; - } - #skipIntro { - position: absolute; - bottom: 6em; - right: 4.5em; - background-color: transparent; - font-size: 1.2em; - } - #skipIntro .emby-button { - text-shadow: 0 0 3px rgba(0, 0, 0, 0.7); - border-radius: var(--rounding); - background-color: rgba(0, 0, 0, 0.3); - will-change: opacity, transform; - opacity: 0; - transition: opacity 0.3s ease-in, transform 0.3s ease-out; - } - #skipIntro .emby-button:hover, - #skipIntro .emby-button:focus { - background-color: rgba(var(--accent),0.7); - transform: scale(1.05); - } - #btnSkipSegmentText { - padding-right: 0.15em; - padding-left: 0.2em; - margin-top: -0.1em; - } - `; - document.querySelector("head").appendChild(styleElement); -} -/** - * Inject the skip intro button into the video player. - * Calling this function is a no-op if the CSS has already been injected. - */ -introSkipper.injectButton = async function () { - // Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one - const preExistingButton = introSkipper.testElement("div.skipIntro"); - if (preExistingButton) { - preExistingButton.style.display = "none"; - } - if (introSkipper.testElement(".btnSkipIntro.injected")) { - introSkipper.d("Button already added"); - return; - } - introSkipper.d("Adding button"); - let config = await introSkipper.secureFetch("Intros/UserInterfaceConfiguration"); - if (!config.SkipButtonVisible) { - introSkipper.d("Not adding button: not visible"); - return; - } - // Construct the skip button div - const button = document.createElement("div"); - button.id = "skipIntro" - button.classList.add("hide"); - button.addEventListener("click", introSkipper.doSkip); - button.innerHTML = ` - - `; - button.dataset["intro_text"] = config.SkipButtonIntroText; - button.dataset["credits_text"] = config.SkipButtonEndCreditsText; - /* - * Alternative workaround for #44. Jellyfin's video component registers a global click handler - * (located at src/controllers/playback/video/index.js:1492) that pauses video playback unless - * the clicked element has a parent with the class "videoOsdBottom" or "upNextContainer". - */ - button.classList.add("upNextContainer"); - // Append the button to the video OSD - let controls = document.querySelector("div#videoOsdPage"); - controls.appendChild(button); -} -/** Tests if the OSD controls are visible. */ -introSkipper.osdVisible = function () { - const osd = document.querySelector("div.videoOsdBottom"); - return osd ? !osd.classList.contains("hide") : false; -} -/** Get the currently playing skippable segment. */ -introSkipper.getCurrentSegment = function (position) { - for (let key in introSkipper.skipSegments) { - const segment = introSkipper.skipSegments[key]; - if ((position >= segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt) || (introSkipper.osdVisible() && position >= segment.IntroStart && position < segment.IntroEnd)) { - segment["SegmentType"] = key; - return segment; - } - } - return { "SegmentType": "None" }; -} -introSkipper.overrideBlur = function(embyButton) { - if (!embyButton.originalBlur) { - embyButton.originalBlur = embyButton.blur; - } - embyButton.blur = function () { - if (!introSkipper.osdVisible() || !embyButton.contains(document.activeElement)) { - embyButton.originalBlur.call(this); - } - }; -}; -introSkipper.restoreBlur = function(embyButton) { - if (embyButton.originalBlur) { - embyButton.blur = embyButton.originalBlur; - delete embyButton.originalBlur; - } -}; -/** Playback position changed, check if the skip button needs to be displayed. */ -introSkipper.videoPositionChanged = function () { - const skipButton = document.querySelector("#skipIntro"); - if (introSkipper.videoPlayer.currentTime === 0 || !skipButton || !introSkipper.allowEnter) return; - const embyButton = skipButton.querySelector(".emby-button"); - const tvLayout = document.documentElement.classList.contains("layout-tv"); - const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime); - switch (segment.SegmentType) { - case "None": - if (embyButton.style.opacity === '0') return; - - embyButton.style.opacity = '0'; - embyButton.addEventListener("transitionend", () => { - skipButton.classList.add("hide"); - if (tvLayout) { - introSkipper.restoreBlur(embyButton); - embyButton.blur(); - } - }, { once: true }); - return; - case "Introduction": - skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.intro_text; - break; - case "Credits": - skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.credits_text; - break; - } - if (!skipButton.classList.contains("hide")) return; - - skipButton.classList.remove("hide"); - embyButton.style.opacity = '1'; - - if (tvLayout) { - introSkipper.overrideBlur(embyButton); - embyButton.focus({ focusVisible: true }); - } -} -introSkipper.throttle = function (func, limit) { - let inThrottle; - return function(...args) { - const context = this; - if (!inThrottle) { - func.apply(context, args); - inThrottle = true; - setTimeout(() => inThrottle = false, limit); - } - }; -} -/** Seeks to the end of the intro. */ -introSkipper.doSkip = introSkipper.throttle(function (e) { - introSkipper.d("Skipping intro"); - introSkipper.d(introSkipper.skipSegments); - const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime); - if (segment.SegmentType === "None") { - console.warn("[intro skipper] doSkip() called without an active segment"); - return; - } - // Disable keydown events - introSkipper.allowEnter = false; - introSkipper.videoPlayer.currentTime = segment.IntroEnd; - // Listen for the seeked event to re-enable keydown events - const onSeeked = async () => { - await new Promise(resolve => setTimeout(resolve, 50)); // Wait 50ms - introSkipper.allowEnter = true; - introSkipper.videoPlayer.removeEventListener('seeked', onSeeked); - }; - introSkipper.videoPlayer.addEventListener('seeked', onSeeked); -}, 3000); -/** Tests if an element with the provided selector exists. */ -introSkipper.testElement = function (selector) { return document.querySelector(selector); } -/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */ -introSkipper.secureFetch = async function (url) { - url = ApiClient.serverAddress() + "/" + url; - const reqInit = { headers: { "Authorization": "MediaBrowser Token=" + ApiClient.accessToken() } }; - const res = await fetch(url, reqInit); - if (res.status !== 200) { throw new Error(`Expected status 200 from ${url}, but got ${res.status}`); } - return await res.json(); -} -/** Handle keydown events. */ -introSkipper.eventHandler = function (e) { - const skipButton = document.querySelector("#skipIntro"); - if (!skipButton || skipButton.classList.contains("hide")) return; - // Ignore all keydown events - if (!introSkipper.allowEnter) { - e.preventDefault(); - return; - } - if (e.key !== "Enter") return; - const embyButton = skipButton.querySelector(".emby-button"); - if (document.documentElement.classList.contains("layout-tv") && embyButton.contains(document.activeElement)) { - e.stopPropagation(); - return; - } - if (document.documentElement.classList.contains("layout-desktop")) { - e.preventDefault(); - e.stopPropagation(); - introSkipper.doSkip(); - } -} -introSkipper.setup(); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt deleted file mode 100644 index 3546645..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt +++ /dev/null @@ -1 +0,0 @@ -unknown diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js deleted file mode 100644 index b4c7401..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js +++ /dev/null @@ -1,234 +0,0 @@ -// re-render the troubleshooter with the latest offset -function renderTroubleshooter() { - paintFingerprintDiff(canvas, lhs, rhs, Number(offset.value)); - findIntros(); -} - -// refresh the upper & lower bounds for the offset -function refreshBounds() { - const len = Math.min(lhs.length, rhs.length) - 1; - offset.min = -1 * len; - offset.max = len; -} - -function findIntros() { - let times = []; - - // get the times of all similar fingerprint points - for (let i in fprDiffs) { - if (fprDiffs[i] > fprDiffMinimum) { - times.push(i * 0.128); - } - } - - // always close the last range - times.push(Number.MAX_VALUE); - - let last = times[0]; - let start = last; - let end = last; - let ranges = []; - - for (let t of times) { - const diff = t - last; - - if (diff <= 3.5) { - end = t; - last = t; - continue; - } - - const dur = Math.round(end - start); - if (dur >= 15) { - ranges.push({ - "start": start, - "end": end, - "duration": dur - }); - } - - start = t; - end = t; - last = t; - } - - const introsLog = document.querySelector("span#intros"); - introsLog.style.position = "relative"; - introsLog.style.left = "115px"; - introsLog.innerHTML = ""; - - const offset = Number(txtOffset.value) * 0.128; - for (let r of ranges) { - let lStart, lEnd, rStart, rEnd; - - if (offset < 0) { - // negative offset, the diff is aligned with the RHS - lStart = r.start - offset; - lEnd = r.end - offset; - rStart = r.start; - rEnd = r.end; - - } else { - // positive offset, the diff is aligned with the LHS - lStart = r.start; - lEnd = r.end; - rStart = r.start + offset; - rEnd = r.end + offset; - } - - const lTitle = selectEpisode1.options[selectEpisode1.selectedIndex].text; - const rTitle = selectEpisode2.options[selectEpisode2.selectedIndex].text; - introsLog.innerHTML += "" + lTitle + ": " + - secondsToString(lStart) + " - " + secondsToString(lEnd) + "
"; - introsLog.innerHTML += "" + rTitle + ": " + - secondsToString(rStart) + " - " + secondsToString(rEnd) + "
"; - } -} - -// find all shifts which align exact matches of audio. -function findExactMatches() { - let shifts = []; - - for (let lhsIndex in lhs) { - let lhsPoint = lhs[lhsIndex]; - let rhsIndex = rhs.findIndex((x) => x === lhsPoint); - - if (rhsIndex === -1) { - continue; - } - - let shift = rhsIndex - lhsIndex; - if (shifts.includes(shift)) { - continue; - } - - shifts.push(shift); - } - - // Only suggest up to 20 shifts - shifts = shifts.slice(0, 20); - - txtSuggested.textContent = "Suggested shifts: "; - if (shifts.length === 0) { - txtSuggested.textContent += "none available"; - } else { - shifts.sort((a, b) => { return a - b }); - txtSuggested.textContent += shifts.join(", "); - } -} - -// The below two functions were modified from https://github.com/dnknth/acoustid-match/blob/ffbf21d8c53c40d3b3b4c92238c35846545d3cd7/fingerprints/static/fingerprints/fputils.js -// Originally licensed as MIT. -function renderFingerprintData(ctx, fp, xor = false) { - const pixels = ctx.createImageData(32, fp.length); - let idx = 0; - - for (let i = 0; i < fp.length; i++) { - for (let j = 0; j < 32; j++) { - if (fp[i] & (1 << j)) { - pixels.data[idx + 0] = 255; - pixels.data[idx + 1] = 255; - pixels.data[idx + 2] = 255; - - } else { - pixels.data[idx + 0] = 0; - pixels.data[idx + 1] = 0; - pixels.data[idx + 2] = 0; - } - - pixels.data[idx + 3] = 255; - idx += 4; - } - } - - if (!xor) { - return pixels; - } - - // if rendering the XOR of the fingerprints, count how many bits are different at each timecode - fprDiffs = []; - - for (let i = 0; i < fp.length; i++) { - let count = 0; - - for (let j = 0; j < 32; j++) { - if (fp[i] & (1 << j)) { - count++; - } - } - - // push the percentage similarity - fprDiffs[i] = 100 - (count * 100) / 32; - } - - return pixels; -} - -function paintFingerprintDiff(canvas, fp1, fp2, offset) { - if (fp1.length == 0) { - return; - } - - canvas.style.display = "unset"; - - let leftOffset = 0, rightOffset = 0; - if (offset < 0) { - leftOffset -= offset; - } else { - rightOffset += offset; - } - - let fpDiff = []; - fpDiff.length = Math.min(fp1.length, fp2.length) - Math.abs(offset); - for (let i = 0; i < fpDiff.length; i++) { - fpDiff[i] = fp1[i + leftOffset] ^ fp2[i + rightOffset]; - } - - const ctx = canvas.getContext('2d'); - const pixels1 = renderFingerprintData(ctx, fp1); - const pixels2 = renderFingerprintData(ctx, fp2); - const pixelsDiff = renderFingerprintData(ctx, fpDiff, true); - const border = 4; - - canvas.width = pixels1.width + border + // left fingerprint - pixels2.width + border + // right fingerprint - pixelsDiff.width + border // fingerprint diff - + 4; // if diff[x] >= fprDiffMinimum - - canvas.height = Math.max(pixels1.height, pixels2.height) + Math.abs(offset); - - ctx.rect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = "#C5C5C5"; - ctx.fill(); - - // draw left fingerprint - let dx = 0; - ctx.putImageData(pixels1, dx, rightOffset); - dx += pixels1.width + border; - - // draw right fingerprint - ctx.putImageData(pixels2, dx, leftOffset); - dx += pixels2.width + border; - - // draw fingerprint diff - ctx.putImageData(pixelsDiff, dx, Math.abs(offset)); - dx += pixelsDiff.width + border; - - // draw the fingerprint diff similarity indicator - // https://davidmathlogic.com/colorblind/#%23EA3535-%232C92EF - for (let i in fprDiffs) { - const j = Number(i); - const y = Math.abs(offset) + j; - const point = fprDiffs[j]; - - if (point >= 100) { - ctx.fillStyle = "#002FFF" - } else if (point >= fprDiffMinimum) { - ctx.fillStyle = "#2C92EF"; - } else { - ctx.fillStyle = "#EA3535"; - } - - ctx.fillRect(dx, y, 4, 1); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj b/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj deleted file mode 100644 index b7a77b2..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - net8.0 - ConfusedPolarBear.Plugin.IntroSkipper - 0.2.0.9 - 0.2.0.9 - true - true - enable - AllEnabledByDefault - ../jellyfin.ruleset - - - - - - - - - - - - - - - - diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs deleted file mode 100644 index a88e551..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/SkipIntroController.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Mime; -using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; -using MediaBrowser.Common.Api; -using MediaBrowser.Controller.Entities.TV; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers; - -/// -/// Skip intro controller. -/// -[Authorize] -[ApiController] -[Produces(MediaTypeNames.Application.Json)] -public class SkipIntroController : ControllerBase -{ - /// - /// Initializes a new instance of the class. - /// - public SkipIntroController() - { - } - - /// - /// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format. - /// - /// ID of the episode. Required. - /// Timestamps to return. Optional. Defaults to Introduction for backwards compatibility. - /// Episode contains an intro. - /// Failed to find an intro in the provided episode. - /// Detected intro. - [HttpGet("Episode/{id}/IntroTimestamps")] - [HttpGet("Episode/{id}/IntroTimestamps/v1")] - public ActionResult GetIntroTimestamps( - [FromRoute] Guid id, - [FromQuery] AnalysisMode mode = AnalysisMode.Introduction) - { - var intro = GetIntro(id, mode); - - if (intro is null || !intro.Valid) - { - return NotFound(); - } - - return intro; - } - - /// - /// Gets a dictionary of all skippable segments. - /// - /// Media ID. - /// Skippable segments dictionary. - /// Dictionary of skippable segments. - [HttpGet("Episode/{id}/IntroSkipperSegments")] - public ActionResult> GetSkippableSegments([FromRoute] Guid id) - { - var segments = new Dictionary(); - - if (GetIntro(id, AnalysisMode.Introduction) is Intro intro) - { - segments[AnalysisMode.Introduction] = intro; - } - - if (GetIntro(id, AnalysisMode.Credits) is Intro credits) - { - segments[AnalysisMode.Credits] = credits; - } - - return segments; - } - - /// Lookup and return the skippable timestamps for the provided item. - /// Unique identifier of this episode. - /// Mode. - /// Intro object if the provided item has an intro, null otherwise. - private Intro? GetIntro(Guid id, AnalysisMode mode) - { - try - { - var timestamp = mode == AnalysisMode.Introduction ? - Plugin.Instance!.Intros[id] : - Plugin.Instance!.Credits[id]; - - // Operate on a copy to avoid mutating the original Intro object stored in the dictionary. - var segment = new Intro(timestamp); - - var config = Plugin.Instance.Configuration; - segment.IntroEnd -= config.SecondsOfIntroToPlay; - if (config.PersistSkipButton) - { - segment.ShowSkipPromptAt = segment.IntroStart; - segment.HideSkipPromptAt = segment.IntroEnd; - } - else - { - segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment); - segment.HideSkipPromptAt = Math.Min( - segment.IntroStart + config.HidePromptAdjustment, - segment.IntroEnd); - } - - return segment; - } - catch (KeyNotFoundException) - { - return null; - } - } - - /// - /// Erases all previously discovered introduction timestamps. - /// - /// Mode. - /// Erase cache. - /// Operation successful. - /// No content. - [Authorize(Policy = Policies.RequiresElevation)] - [HttpPost("Intros/EraseTimestamps")] - public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false) - { - if (mode == AnalysisMode.Introduction) - { - Plugin.Instance!.Intros.Clear(); - } - else if (mode == AnalysisMode.Credits) - { - Plugin.Instance!.Credits.Clear(); - } - - if (eraseCache) - { - FFmpegWrapper.DeleteCacheFiles(mode); - } - - Plugin.Instance!.SaveTimestamps(mode); - return NoContent(); - } - - /// - /// Erases all previously cached introduction fingerprints. - /// - /// Operation successful. - /// No content. - [Authorize(Policy = "RequiresElevation")] - [HttpPost("Intros/CleanCache")] - public ActionResult CleanIntroCache() - { - FFmpegWrapper.CleanCacheFiles(); - return NoContent(); - } - - /// - /// Get all introductions or credits. Only used by the end to end testing script. - /// - /// Mode. - /// All timestamps have been returned. - /// List of IntroWithMetadata objects. - [Authorize(Policy = Policies.RequiresElevation)] - [HttpGet("Intros/All")] - public ActionResult> GetAllTimestamps( - [FromQuery] AnalysisMode mode = AnalysisMode.Introduction) - { - List intros = new(); - - var timestamps = mode == AnalysisMode.Introduction ? - Plugin.Instance!.Intros : - Plugin.Instance!.Credits; - - // Get metadata for all intros - foreach (var intro in timestamps) - { - // Get the details of the item from Jellyfin - var rawItem = Plugin.Instance.GetItem(intro.Key); - if (rawItem == null || rawItem is not Episode episode) - { - throw new InvalidCastException("Unable to cast item id " + intro.Key + " to an Episode"); - } - - // Associate the metadata with the intro - intros.Add( - new IntroWithMetadata( - episode.SeriesName, - episode.AiredSeasonNumber ?? 0, - episode.Name, - intro.Value)); - } - - return intros; - } - - /// - /// Gets the user interface configuration. - /// - /// UserInterfaceConfiguration returned. - /// UserInterfaceConfiguration. - [HttpGet] - [Route("Intros/UserInterfaceConfiguration")] - public ActionResult GetUserInterfaceConfiguration() - { - var config = Plugin.Instance!.Configuration; - return new UserInterfaceConfiguration( - config.SkipButtonVisible, - config.SkipButtonIntroText, - config.SkipButtonEndCreditsText); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/TroubleshootingController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/TroubleshootingController.cs deleted file mode 100644 index c64ae6f..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/TroubleshootingController.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.Net.Mime; -using System.Text; -using MediaBrowser.Common; -using MediaBrowser.Common.Api; -using MediaBrowser.Common.Configuration; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers; - -/// -/// Troubleshooting controller. -/// -[Authorize(Policy = Policies.RequiresElevation)] -[ApiController] -[Produces(MediaTypeNames.Application.Json)] -[Route("IntroSkipper")] -public class TroubleshootingController : ControllerBase -{ - private readonly IApplicationHost _applicationHost; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// Application host. - /// Logger. - public TroubleshootingController( - IApplicationHost applicationHost, - ILogger logger) - { - _applicationHost = applicationHost; - _logger = logger; - } - - /// - /// Gets a Markdown formatted support bundle. - /// - /// Support bundle created. - /// Support bundle. - [HttpGet("SupportBundle")] - [Produces(MediaTypeNames.Text.Plain)] - public ActionResult GetSupportBundle() - { - ArgumentNullException.ThrowIfNull(Plugin.Instance); - - var bundle = new StringBuilder(); - - bundle.Append("* Jellyfin version: "); - bundle.Append(_applicationHost.ApplicationVersionString); - bundle.Append('\n'); - - var version = Plugin.Instance.Version.ToString(3); - - try - { - var commit = Plugin.Instance.GetCommit(); - if (!string.IsNullOrWhiteSpace(commit)) - { - version += string.Concat("+", commit.AsSpan(0, 12)); - } - } - catch (Exception ex) - { - _logger.LogWarning("Unable to append commit to version: {Exception}", ex); - } - - bundle.Append("* Plugin version: "); - bundle.Append(version); - bundle.Append('\n'); - - bundle.Append("* Queue contents: "); - bundle.Append(Plugin.Instance.TotalQueued); - bundle.Append(" episodes, "); - bundle.Append(Plugin.Instance.TotalSeasons); - bundle.Append(" seasons\n"); - - bundle.Append("* Warnings: `"); - bundle.Append(WarningManager.GetWarnings()); - bundle.Append("`\n"); - - bundle.Append(FFmpegWrapper.GetChromaprintLogs()); - - return bundle.ToString(); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs deleted file mode 100644 index a9e2569..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Net.Mime; -using MediaBrowser.Common.Api; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers; - -/// -/// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis. -/// -[Authorize(Policy = Policies.RequiresElevation)] -[ApiController] -[Produces(MediaTypeNames.Application.Json)] -[Route("Intros")] -public class VisualizationController : ControllerBase -{ - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// Logger. - public VisualizationController(ILogger logger) - { - _logger = logger; - } - - /// - /// Returns all show names and seasons. - /// - /// Dictionary of show names to a list of season names. - [HttpGet("Shows")] - public ActionResult>> GetShowSeasons() - { - _logger.LogDebug("Returning season names by series"); - - var showSeasons = new Dictionary>(); - - // Loop through all seasons in the analysis queue - foreach (var kvp in Plugin.Instance!.QueuedMediaItems) - { - // Check that this season contains at least one episode. - var episodes = kvp.Value; - if (episodes is null || episodes.Count == 0) - { - _logger.LogDebug("Skipping season {Id} (null or empty)", kvp.Key); - continue; - } - - // Peek at the top episode from this season and store the series name and season number. - var first = episodes[0]; - var series = first.SeriesName; - var season = GetSeasonName(first); - - // Validate the series and season before attempting to store it. - if (string.IsNullOrWhiteSpace(series) || string.IsNullOrWhiteSpace(season)) - { - _logger.LogDebug("Skipping season {Id} (no name or number)", kvp.Key); - continue; - } - - // TryAdd is used when adding the HashSet since it is a no-op if one was already created for this series. - showSeasons.TryAdd(series, new HashSet()); - showSeasons[series].Add(season); - } - - return showSeasons; - } - - /// - /// Returns the names and unique identifiers of all episodes in the provided season. - /// - /// Show name. - /// Season name. - /// List of episode titles. - [HttpGet("Show/{Series}/{Season}")] - public ActionResult> GetSeasonEpisodes( - [FromRoute] string series, - [FromRoute] string season) - { - var visualEpisodes = new List(); - - if (!LookupSeasonByName(series, season, out var episodes)) - { - return NotFound(); - } - - foreach (var e in episodes) - { - visualEpisodes.Add(new EpisodeVisualization(e.EpisodeId, e.Name)); - } - - return visualEpisodes; - } - - /// - /// Fingerprint the provided episode and returns the uncompressed fingerprint data points. - /// - /// Episode id. - /// Read only collection of fingerprint points. - [HttpGet("Episode/{Id}/Chromaprint")] - public ActionResult GetEpisodeFingerprint([FromRoute] Guid id) - { - // Search through all queued episodes to find the requested id - foreach (var season in Plugin.Instance!.QueuedMediaItems) - { - foreach (var needle in season.Value) - { - if (needle.EpisodeId == id) - { - return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction); - } - } - } - - return NotFound(); - } - - /// - /// Erases all timestamps for the provided season. - /// - /// Show name. - /// Season name. - /// Erase cache. - /// Season timestamps erased. - /// Unable to find season in provided series. - /// No content. - [HttpDelete("Show/{Series}/{Season}")] - public ActionResult EraseSeason([FromRoute] string series, [FromRoute] string season, [FromQuery] bool eraseCache = false) - { - if (!LookupSeasonByName(series, season, out var episodes)) - { - return NotFound(); - } - - _logger.LogInformation("Erasing timestamps for {Series} {Season} at user request", series, season); - - foreach (var e in episodes) - { - Plugin.Instance!.Intros.TryRemove(e.EpisodeId, out _); - Plugin.Instance!.Credits.TryRemove(e.EpisodeId, out _); - if (eraseCache) - { - FFmpegWrapper.DeleteEpisodeCache(e.EpisodeId); - } - } - - Plugin.Instance!.SaveTimestamps(AnalysisMode.Introduction); - Plugin.Instance!.SaveTimestamps(AnalysisMode.Credits); - - return NoContent(); - } - - /// - /// Updates the timestamps for the provided episode. - /// - /// Episode ID to update timestamps for. - /// New introduction start and end times. - /// New introduction timestamps saved. - /// No content. - [HttpPost("Episode/{Id}/UpdateIntroTimestamps")] - public ActionResult UpdateTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps) - { - var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd); - Plugin.Instance!.Intros[id] = new Intro(id, tr); - Plugin.Instance.SaveTimestamps(AnalysisMode.Introduction); - - return NoContent(); - } - - private string GetSeasonName(QueuedEpisode episode) - { - return "Season " + episode.SeasonNumber.ToString(CultureInfo.InvariantCulture); - } - - /// - /// Lookup a named season of a series and return all queued episodes. - /// - /// Series name. - /// Season name. - /// Episodes. - /// Boolean indicating if the requested season was found. - private bool LookupSeasonByName(string series, string season, out List episodes) - { - foreach (var queuedEpisodes in Plugin.Instance!.QueuedMediaItems) - { - var first = queuedEpisodes.Value[0]; - var firstSeasonName = GetSeasonName(first); - - // Assert that the queued episode series and season are equal to what was requested - if ( - !string.Equals(first.SeriesName, series, StringComparison.OrdinalIgnoreCase) || - !string.Equals(firstSeasonName, season, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - episodes = queuedEpisodes.Value; - return true; - } - - episodes = new List(); - return false; - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisMode.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisMode.cs deleted file mode 100644 index 7ed0367..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisMode.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Type of media file analysis to perform. -/// -public enum AnalysisMode -{ - /// - /// Detect introduction sequences. - /// - Introduction, - - /// - /// Detect credits. - /// - Credits, -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs deleted file mode 100644 index df3a957..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// A frame of video that partially (or entirely) consists of black pixels. -/// -public class BlackFrame -{ - /// - /// Initializes a new instance of the class. - /// - /// Percentage of the frame that is black. - /// Time this frame appears at. - public BlackFrame(int percent, double time) - { - Percentage = percent; - Time = time; - } - - /// - /// Gets or sets the percentage of the frame that is black. - /// - public int Percentage { get; set; } - - /// - /// Gets or sets the time (in seconds) this frame appeared at. - /// - public double Time { get; set; } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs deleted file mode 100644 index 159e950..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Taken from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL. -/// -public enum EdlAction -{ - /// - /// Do not create EDL files. - /// - None = -1, - - /// - /// Completely remove the intro from playback as if it was never in the original video. - /// - Cut, - - /// - /// Mute audio, continue playback. - /// - Mute, - - /// - /// Inserts a new scene marker. - /// - SceneMarker, - - /// - /// Automatically skip the intro once during playback. - /// - CommercialBreak, - - /// - /// Show a skip button. - /// - Intro, - - /// - /// Show a skip button. - /// - Credit, -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/EpisodeVisualization.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/EpisodeVisualization.cs deleted file mode 100644 index 64bf022..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/EpisodeVisualization.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Episode name and internal ID as returned by the visualization controller. -/// -public class EpisodeVisualization -{ - /// - /// Initializes a new instance of the class. - /// - /// Episode id. - /// Episode name. - public EpisodeVisualization(Guid id, string name) - { - Id = id; - Name = name; - } - - /// - /// Gets the id. - /// - public Guid Id { get; private set; } - - /// - /// Gets the name. - /// - public string Name { get; private set; } = string.Empty; -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/FingerprintException.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/FingerprintException.cs deleted file mode 100644 index 772b03c..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/FingerprintException.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Exception raised when an error is encountered analyzing audio. -/// -public class FingerprintException : Exception -{ - /// - /// Initializes a new instance of the class. - /// - public FingerprintException() - { - } - - /// - /// Initializes a new instance of the class. - /// - /// Exception message. - public FingerprintException(string message) : base(message) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// Exception message. - /// Inner exception. - public FingerprintException(string message, Exception inner) : base(message, inner) - { - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs deleted file mode 100644 index b1761e5..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -using System.Globalization; -using System.Text.Json.Serialization; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Result of fingerprinting and analyzing two episodes in a season. -/// All times are measured in seconds relative to the beginning of the media file. -/// -public class Intro -{ - /// - /// Initializes a new instance of the class. - /// - /// Episode. - /// Introduction time range. - public Intro(Guid episode, TimeRange intro) - { - EpisodeId = episode; - IntroStart = intro.Start; - IntroEnd = intro.End; - } - - /// - /// Initializes a new instance of the class. - /// - /// Episode. - public Intro(Guid episode) - { - EpisodeId = episode; - IntroStart = 0; - IntroEnd = 0; - } - - /// - /// Initializes a new instance of the class. - /// - /// intro. - public Intro(Intro intro) - { - EpisodeId = intro.EpisodeId; - IntroStart = intro.IntroStart; - IntroEnd = intro.IntroEnd; - } - - /// - /// Initializes a new instance of the class. - /// - public Intro() - { - } - - /// - /// Gets or sets the Episode ID. - /// - public Guid EpisodeId { get; set; } - - /// - /// Gets a value indicating whether this introduction is valid or not. - /// Invalid results must not be returned through the API. - /// - public bool Valid => IntroEnd > 0; - - /// - /// Gets the duration of this intro. - /// - [JsonIgnore] - public double Duration => IntroEnd - IntroStart; - - /// - /// Gets or sets the introduction sequence start time. - /// - public double IntroStart { get; set; } - - /// - /// Gets or sets the introduction sequence end time. - /// - public double IntroEnd { get; set; } - - /// - /// Gets or sets the recommended time to display the skip intro prompt. - /// - public double ShowSkipPromptAt { get; set; } - - /// - /// Gets or sets the recommended time to hide the skip intro prompt. - /// - public double HideSkipPromptAt { get; set; } - - /// - /// Convert this Intro object to a Kodi compatible EDL entry. - /// - /// User specified configuration EDL action. - /// String. - public string ToEdl(EdlAction action) - { - if (action == EdlAction.None) - { - throw new ArgumentException("Cannot serialize an EdlAction of None"); - } - - var start = Math.Round(IntroStart, 2); - var end = Math.Round(IntroEnd, 2); - - return string.Format(CultureInfo.InvariantCulture, "{0} {1} {2}", start, end, (int)action); - } -} - -/// -/// An Intro class with episode metadata. Only used in end to end testing programs. -/// -public class IntroWithMetadata : Intro -{ - /// - /// Initializes a new instance of the class. - /// - /// Series name. - /// Season number. - /// Episode title. - /// Intro timestamps. - public IntroWithMetadata(string series, int season, string title, Intro intro) - { - Series = series; - Season = season; - Title = title; - - EpisodeId = intro.EpisodeId; - IntroStart = intro.IntroStart; - IntroEnd = intro.IntroEnd; - } - - /// - /// Gets or sets the series name of the TV episode associated with this intro. - /// - public string Series { get; set; } - - /// - /// Gets or sets the season number of the TV episode associated with this intro. - /// - public int Season { get; set; } - - /// - /// Gets or sets the title of the TV episode associated with this intro. - /// - public string Title { get; set; } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/PluginWarning.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/PluginWarning.cs deleted file mode 100644 index 4103c77..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/PluginWarning.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Support bundle warning. -/// -[Flags] -public enum PluginWarning -{ - /// - /// No warnings have been added. - /// - None = 0, - - /// - /// Attempted to add skip button to web interface, but was unable to. - /// - UnableToAddSkipButton = 1, - - /// - /// At least one media file on the server was unable to be fingerprinted by Chromaprint. - /// - InvalidChromaprintFingerprint = 2, - - /// - /// The version of ffmpeg installed on the system is not compatible with the plugin. - /// - IncompatibleFFmpegBuild = 4, -} - -/// -/// Warning manager. -/// -public static class WarningManager -{ - private static PluginWarning warnings; - - /// - /// Set warning. - /// - /// Warning. - public static void SetFlag(PluginWarning warning) - { - warnings |= warning; - } - - /// - /// Clear warnings. - /// - public static void Clear() - { - warnings = PluginWarning.None; - } - - /// - /// Get warnings. - /// - /// Warnings. - public static string GetWarnings() - { - return warnings.ToString(); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs deleted file mode 100644 index bb65789..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/QueuedEpisode.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Episode queued for analysis. -/// -public class QueuedEpisode -{ - /// - /// Gets or sets the series name. - /// - public string SeriesName { get; set; } = string.Empty; - - /// - /// Gets or sets the season number. - /// - public int SeasonNumber { get; set; } - - /// - /// Gets or sets the episode id. - /// - public Guid EpisodeId { get; set; } - - /// - /// Gets or sets the full path to episode. - /// - public string Path { get; set; } = string.Empty; - - /// - /// Gets or sets the name of the episode. - /// - public string Name { get; set; } = string.Empty; - - /// - /// Gets or sets the timestamp (in seconds) to stop searching for an introduction at. - /// - public int IntroFingerprintEnd { get; set; } - - /// - /// Gets or sets the timestamp (in seconds) to start looking for end credits at. - /// - public int CreditsFingerprintStart { get; set; } - - /// - /// Gets or sets the total duration of this media file (in seconds). - /// - public int Duration { get; set; } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs deleted file mode 100644 index 451df2f..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/TimeRange.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -#pragma warning disable CA1036 // Override methods on comparable types - -/// -/// Range of contiguous time. -/// -public class TimeRange : IComparable -{ - /// - /// Initializes a new instance of the class. - /// - public TimeRange() - { - Start = 0; - End = 0; - } - - /// - /// Initializes a new instance of the class. - /// - /// Time range start. - /// Time range end. - public TimeRange(double start, double end) - { - Start = start; - End = end; - } - - /// - /// Initializes a new instance of the class. - /// - /// Original TimeRange. - public TimeRange(TimeRange original) - { - Start = original.Start; - End = original.End; - } - - /// - /// Gets or sets the time range start (in seconds). - /// - public double Start { get; set; } - - /// - /// Gets or sets the time range end (in seconds). - /// - public double End { get; set; } - - /// - /// Gets the duration of this time range (in seconds). - /// - public double Duration => End - Start; - - /// - /// Compare TimeRange durations. - /// - /// Object to compare with. - /// int. - public int CompareTo(object? obj) - { - if (!(obj is TimeRange tr)) - { - throw new ArgumentException("obj must be a TimeRange"); - } - - return tr.Duration.CompareTo(Duration); - } - - /// - /// Tests if this TimeRange object intersects the provided TimeRange. - /// - /// Second TimeRange object to test. - /// true if tr intersects the current TimeRange, false otherwise. - public bool Intersects(TimeRange tr) - { - return - (Start < tr.Start && tr.Start < End) || - (Start < tr.End && tr.End < End); - } -} - -#pragma warning restore CA1036 - -/// -/// Time range helpers. -/// -public static class TimeRangeHelpers -{ - /// - /// Finds the longest contiguous time range. - /// - /// Sorted timestamps to search. - /// Maximum distance permitted between contiguous timestamps. - /// The longest contiguous time range (if one was found), or null (if none was found). - public static TimeRange? FindContiguous(double[] times, double maximumDistance) - { - if (times.Length == 0) - { - return null; - } - - Array.Sort(times); - - var ranges = new List(); - var currentRange = new TimeRange(times[0], times[0]); - - // For all provided timestamps, check if it is contiguous with its neighbor. - for (var i = 0; i < times.Length - 1; i++) - { - var current = times[i]; - var next = times[i + 1]; - - if (next - current <= maximumDistance) - { - currentRange.End = next; - continue; - } - - ranges.Add(new TimeRange(currentRange)); - currentRange = new TimeRange(next, next); - } - - // Find and return the longest contiguous range. - ranges.Sort(); - - return (ranges.Count > 0) ? ranges[0] : null; - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs b/ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs deleted file mode 100644 index 9b5b296..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.IO; -using Microsoft.Extensions.Logging; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Update EDL files associated with a list of episodes. -/// -public static class EdlManager -{ - private static ILogger? _logger; - - /// - /// Initialize EDLManager with a logger. - /// - /// ILogger. - public static void Initialize(ILogger logger) - { - _logger = logger; - } - - /// - /// Logs the configuration that will be used during EDL file creation. - /// - public static void LogConfiguration() - { - if (_logger is null) - { - throw new InvalidOperationException("Logger must not be null"); - } - - var config = Plugin.Instance!.Configuration; - - if (config.EdlAction == EdlAction.None) - { - _logger.LogDebug("EDL action: None - taking no further action"); - return; - } - - _logger.LogDebug("EDL action: {Action}", config.EdlAction); - _logger.LogDebug("Regenerate EDL files: {Regenerate}", config.RegenerateEdlFiles); - } - - /// - /// If the EDL action is set to a value other than None, update EDL files for the provided episodes. - /// - /// Episodes to update EDL files for. - public static void UpdateEDLFiles(ReadOnlyCollection episodes) - { - var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles; - var action = Plugin.Instance.Configuration.EdlAction; - if (action == EdlAction.None) - { - _logger?.LogDebug("EDL action is set to none, not updating EDL files"); - return; - } - - _logger?.LogDebug("Updating EDL files with action {Action}", action); - - foreach (var episode in episodes) - { - var id = episode.EpisodeId; - - bool hasIntro = Plugin.Instance!.Intros.TryGetValue(id, out var intro) && intro.Valid; - bool hasCredit = Plugin.Instance!.Credits.TryGetValue(id, out var credit) && credit.Valid; - - if (!hasIntro && !hasCredit) - { - _logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id); - continue; - } - - var edlPath = GetEdlPath(Plugin.Instance.GetItemPath(id)); - - _logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath); - - if (!regenerate && File.Exists(edlPath)) - { - _logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath); - continue; - } - - var edlContent = string.Empty; - - if (hasIntro) - { - edlContent += intro?.ToEdl(action); - } - - if (hasCredit) - { - if (edlContent.Length > 0) - { - edlContent += Environment.NewLine; - } - - if (action == EdlAction.Intro) - { - edlContent += credit?.ToEdl(EdlAction.Credit); - } - else - { - edlContent += credit?.ToEdl(action); - } - } - - File.WriteAllText(edlPath, edlContent); - } - } - - /// - /// Given the path to an episode, return the path to the associated EDL file. - /// - /// Full path to episode. - /// Full path to EDL file. - public static string GetEdlPath(string mediaPath) - { - return Path.ChangeExtension(mediaPath, "edl"); - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs deleted file mode 100644 index 3d22d1c..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Entrypoint.cs +++ /dev/null @@ -1,364 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Server entrypoint. -/// -public class Entrypoint : IHostedService, IDisposable -{ - private readonly IUserManager _userManager; - private readonly IUserViewManager _userViewManager; - private readonly ITaskManager _taskManager; - private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; - private readonly object _pathRestrictionsLock = new(); - private Timer _queueTimer; - private bool _analyzeAgain; - private List _pathRestrictions = new List(); - private static CancellationTokenSource? _cancellationTokenSource; - private static ManualResetEventSlim _autoTaskCompletEvent = new ManualResetEventSlim(false); - - /// - /// Initializes a new instance of the class. - /// - /// User manager. - /// User view manager. - /// Library manager. - /// Task manager. - /// Logger. - /// Logger factory. - public Entrypoint( - IUserManager userManager, - IUserViewManager userViewManager, - ILibraryManager libraryManager, - ITaskManager taskManager, - ILogger logger, - ILoggerFactory loggerFactory) - { - _userManager = userManager; - _userViewManager = userViewManager; - _libraryManager = libraryManager; - _taskManager = taskManager; - _logger = logger; - _loggerFactory = loggerFactory; - - _queueTimer = new Timer( - OnTimerCallback, - null, - Timeout.InfiniteTimeSpan, - Timeout.InfiniteTimeSpan); - } - - /// - /// Gets State of the automatic task. - /// - public static TaskState AutomaticTaskState - { - get - { - if (_cancellationTokenSource is not null) - { - return _cancellationTokenSource.IsCancellationRequested - ? TaskState.Cancelling - : TaskState.Running; - } - - return TaskState.Idle; - } - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _libraryManager.ItemAdded += OnItemAdded; - _libraryManager.ItemUpdated += OnItemModified; - _taskManager.TaskCompleted += OnLibraryRefresh; - - FFmpegWrapper.Logger = _logger; - - try - { - // Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible - _logger.LogInformation("Running startup enqueue"); - var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager); - queueManager?.GetMediaItems(); - } - catch (Exception ex) - { - _logger.LogError("Unable to run startup enqueue: {Exception}", ex); - } - - return Task.CompletedTask; - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _libraryManager.ItemAdded -= OnItemAdded; - _libraryManager.ItemUpdated -= OnItemModified; - _taskManager.TaskCompleted -= OnLibraryRefresh; - - // Stop the timer - _queueTimer.Change(Timeout.Infinite, 0); - - if (_cancellationTokenSource != null) // Null Check - { - _cancellationTokenSource.Dispose(); - _cancellationTokenSource = null; - } - - return Task.CompletedTask; - } - - // Disclose source for inspiration - // Implementation based on the principles of jellyfin-plugin-media-analyzer: - // https://github.com/endrl/jellyfin-plugin-media-analyzer - - /// - /// Library item was added. - /// - /// The sending entity. - /// The . - private void OnItemAdded(object? sender, ItemChangeEventArgs itemChangeEventArgs) - { - // Don't do anything if auto detection is disabled - if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance.Configuration.AutoDetectCredits) - { - return; - } - - // Don't do anything if it's not a supported media type - if (itemChangeEventArgs.Item is not Episode) - { - return; - } - - if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual) - { - return; - } - - lock (_pathRestrictionsLock) - { - _pathRestrictions.Add(itemChangeEventArgs.Item.ContainingFolderPath); - } - - StartTimer(); - } - - /// - /// Library item was modified. - /// - /// The sending entity. - /// The . - private void OnItemModified(object? sender, ItemChangeEventArgs itemChangeEventArgs) - { - // Don't do anything if auto detection is disabled - if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance.Configuration.AutoDetectCredits) - { - return; - } - - // Don't do anything if it's not a supported media type - if (itemChangeEventArgs.Item is not Episode) - { - return; - } - - if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual) - { - return; - } - - lock (_pathRestrictionsLock) - { - _pathRestrictions.Add(itemChangeEventArgs.Item.ContainingFolderPath); - } - - StartTimer(); - } - - /// - /// TaskManager task ended. - /// - /// The sending entity. - /// The . - private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs) - { - // Don't do anything if auto detection is disabled - if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance.Configuration.AutoDetectCredits) - { - return; - } - - var result = eventArgs.Result; - - if (result.Key != "RefreshLibrary") - { - return; - } - - if (result.Status != TaskCompletionStatus.Completed) - { - return; - } - - // Unless user initiated, this is likely an overlap - if (AutomaticTaskState == TaskState.Running) - { - return; - } - - StartTimer(); - } - - /// - /// Start timer to debounce analyzing. - /// - private void StartTimer() - { - if (AutomaticTaskState == TaskState.Running) - { - _analyzeAgain = true; - } - else if (AutomaticTaskState == TaskState.Idle) - { - _logger.LogDebug("Media Library changed, analyzis will start soon!"); - _queueTimer.Change(TimeSpan.FromMilliseconds(20000), Timeout.InfiniteTimeSpan); - } - } - - /// - /// Wait for timer callback to be completed. - /// - private void OnTimerCallback(object? state) - { - try - { - PerformAnalysis(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in PerformAnalysis"); - } - - // Clean up - Plugin.Instance!.Configuration.PathRestrictions.Clear(); - _cancellationTokenSource = null; - _autoTaskCompletEvent.Set(); - } - - /// - /// Wait for timer to be completed. - /// - private void PerformAnalysis() - { - _logger.LogInformation("Initiate automatic analysis task."); - _autoTaskCompletEvent.Reset(); - - using (_cancellationTokenSource = new CancellationTokenSource()) - using (ScheduledTaskSemaphore.Acquire(-1, _cancellationTokenSource.Token)) - { - lock (_pathRestrictionsLock) - { - foreach (var path in _pathRestrictions) - { - Plugin.Instance!.Configuration.PathRestrictions.Add(path); - } - - _pathRestrictions.Clear(); - } - - _analyzeAgain = false; - var progress = new Progress(); - var modes = new List(); - var tasklogger = _loggerFactory.CreateLogger("DefaultLogger"); - - if (Plugin.Instance!.Configuration.AutoDetectIntros && Plugin.Instance.Configuration.AutoDetectCredits) - { - modes.Add(AnalysisMode.Introduction); - modes.Add(AnalysisMode.Credits); - tasklogger = _loggerFactory.CreateLogger(); - } - else if (Plugin.Instance.Configuration.AutoDetectIntros) - { - modes.Add(AnalysisMode.Introduction); - tasklogger = _loggerFactory.CreateLogger(); - } - else if (Plugin.Instance.Configuration.AutoDetectCredits) - { - modes.Add(AnalysisMode.Credits); - tasklogger = _loggerFactory.CreateLogger(); - } - - var baseCreditAnalyzer = new BaseItemAnalyzerTask( - modes.AsReadOnly(), - tasklogger, - _loggerFactory, - _libraryManager); - - baseCreditAnalyzer.AnalyzeItems(progress, _cancellationTokenSource.Token); - - // New item detected, start timer again - if (_analyzeAgain && !_cancellationTokenSource.IsCancellationRequested) - { - _logger.LogInformation("Analyzing ended, but we need to analyze again!"); - StartTimer(); - } - } - } - - /// - /// Method to cancel the automatic task. - /// - /// Cancellation token. - public static void CancelAutomaticTask(CancellationToken cancellationToken) - { - if (_cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested) - { - try - { - _cancellationTokenSource.Cancel(); - } - catch (ObjectDisposedException) - { - _cancellationTokenSource = null; - } - } - - _autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal - } - - /// - /// Dispose. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Protected dispose. - /// - /// Dispose. - protected virtual void Dispose(bool dispose) - { - if (!dispose) - { - _queueTimer.Dispose(); - } - } -} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs deleted file mode 100644 index 71ae5b5..0000000 --- a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs +++ /dev/null @@ -1,726 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using Microsoft.Extensions.Logging; - -namespace ConfusedPolarBear.Plugin.IntroSkipper; - -/// -/// Wrapper for libchromaprint and the silencedetect filter. -/// -public static class FFmpegWrapper -{ - /// - /// Used with FFmpeg's silencedetect filter to extract the start and end times of silence. - /// - private static readonly Regex SilenceDetectionExpression = new( - "silence_(?start|end): (?