Repository: endrl/jellyfin-plugin-media-analyzer Branch: master Commit: 6bb1c3e8182f Files: 91 Total size: 345.1 KB Directory structure: gitextract_z9ywbwcb/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── feature_request.md │ ├── dependabot.yml │ └── workflows/ │ ├── build.yml │ ├── package.sh │ └── publish.yml ├── .gitignore ├── .vscode/ │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── ACKNOWLEDGEMENTS.md ├── CHANGELOG.md ├── Jellyfin.Plugin.MediaAnalyzer/ │ ├── Analyzers/ │ │ ├── BlackFrameAnalyzer.cs │ │ ├── ChapterAnalyzer.cs │ │ ├── ChromaprintAnalyzer.cs │ │ └── IMediaFileAnalyzer.cs │ ├── Configuration/ │ │ ├── PluginConfiguration.cs │ │ ├── configPage.html │ │ ├── version.txt │ │ └── visualizer.js │ ├── Controllers/ │ │ ├── MediaAnalyzerController.cs │ │ ├── TroubleshootingController.cs │ │ └── VisualizationController.cs │ ├── Data/ │ │ ├── AnalyzerType.cs │ │ ├── BlackFrame.cs │ │ ├── EpisodeVisualization.cs │ │ ├── FingerprintException.cs │ │ ├── IntroWithMetadata.cs │ │ ├── MediaSegmentsDb.cs │ │ ├── QueuedMedia.cs │ │ ├── Segment.cs │ │ ├── TimeRange.cs │ │ ├── TimeRangeHelpers.cs │ │ └── WarningManager.cs │ ├── Db/ │ │ ├── MediaAnalyzerDbContext.cs │ │ ├── MediaAnalyzerDbFactory.cs │ │ ├── SegmentMetadata.cs │ │ └── SegmentMetadataDb.cs │ ├── Entrypoint/ │ │ └── LibraryChangedEntrypoint.cs │ ├── FFmpegWrapper.cs │ ├── Helper/ │ │ └── Utils.cs │ ├── Jellyfin.Plugin.MediaAnalyzer.csproj │ ├── Migrations/ │ │ ├── 20230525091047_CreateBlacklistSegment.Designer.cs │ │ ├── 20230525091047_CreateBlacklistSegment.cs │ │ ├── 20240903114429_CreateSegmentMetadata.Designer.cs │ │ ├── 20240903114429_CreateSegmentMetadata.cs │ │ └── MediaAnalyzerDbContextModelSnapshot.cs │ ├── Plugin.cs │ ├── QueueManager.cs │ └── ScheduledTasks/ │ ├── AnalyzeMedia.cs │ └── BaseItemAnalyzerTask.cs ├── Jellyfin.Plugin.MediaAnalyzer.Tests/ │ ├── Jellyfin.Plugin.MediaAnalyzer.Tests.csproj │ ├── TestAudioFingerprinting.cs │ ├── TestBlackFrames.cs │ ├── TestChapterAnalyzer.cs │ ├── TestContiguous.cs │ ├── TestWarnings.cs │ ├── audio/ │ │ └── README.txt │ └── e2e_tests/ │ ├── .gitignore │ ├── README.md │ ├── build.sh │ ├── config_sample.jsonc │ ├── docker-compose.yml │ ├── selenium/ │ │ ├── main.py │ │ └── requirements.txt │ ├── verifier/ │ │ ├── go.mod │ │ ├── http.go │ │ ├── main.go │ │ ├── report.html │ │ ├── report_comparison.go │ │ ├── report_comparison_util.go │ │ ├── report_generator.go │ │ ├── schema_validation.go │ │ └── structs/ │ │ ├── intro.go │ │ ├── plugin_configuration.go │ │ ├── public_info.go │ │ └── report.go │ └── wrapper/ │ ├── exec.go │ ├── exec_test.go │ ├── go.mod │ ├── library.json │ ├── main.go │ ├── setup.go │ └── structs.go ├── Jellyfin.Plugin.MediaAnalyzer.sln ├── LICENSE ├── README.md ├── build.yaml ├── docs/ │ └── release.md └── jellyfin.ruleset ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # With more recent updates Visual Studio 2017 supports EditorConfig files out of the box # Visual Studio Code needs an extension: https://github.com/editorconfig/editorconfig-vscode # For emacs, vim, np++ and other editors, see here: https://github.com/editorconfig ############################### # Core EditorConfig Options # ############################### root = true # All files [*] indent_style = space indent_size = 4 charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true end_of_line = lf max_line_length = off # YAML indentation [*.{yml,yaml}] indent_size = 2 # XML indentation [*.{csproj,xml}] indent_size = 2 ############################### # .NET Coding Conventions # ############################### [*.{cs,vb}] # Organize usings dotnet_sort_system_directives_first = true # this. preferences dotnet_style_qualification_for_field = false:silent dotnet_style_qualification_for_property = false:silent dotnet_style_qualification_for_method = false:silent dotnet_style_qualification_for_event = false:silent # Language keywords vs BCL types preferences dotnet_style_predefined_type_for_locals_parameters_members = true:silent dotnet_style_predefined_type_for_member_access = true:silent # Parentheses preferences dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent # Modifier preferences dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent dotnet_style_readonly_field = true:suggestion # Expression-level preferences dotnet_style_object_initializer = true:suggestion dotnet_style_collection_initializer = true:suggestion dotnet_style_explicit_tuple_names = true:suggestion dotnet_style_null_propagation = true:suggestion dotnet_style_coalesce_expression = true:suggestion dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent dotnet_style_prefer_inferred_tuple_names = true:suggestion dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_auto_properties = true:silent dotnet_style_prefer_conditional_expression_over_assignment = true:silent dotnet_style_prefer_conditional_expression_over_return = true:silent ############################### # Naming Conventions # ############################### # Style Definitions (From Roslyn) # Non-private static fields are PascalCase dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected dotnet_naming_symbols.non_private_static_fields.required_modifiers = static dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case # Constants are PascalCase dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style dotnet_naming_symbols.constants.applicable_kinds = field, local dotnet_naming_symbols.constants.required_modifiers = const dotnet_naming_style.constant_style.capitalization = pascal_case # Static fields are camelCase and start with s_ dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style dotnet_naming_symbols.static_fields.applicable_kinds = field dotnet_naming_symbols.static_fields.required_modifiers = static dotnet_naming_style.static_field_style.capitalization = camel_case dotnet_naming_style.static_field_style.required_prefix = _ # Instance fields are camelCase and start with _ dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style dotnet_naming_symbols.instance_fields.applicable_kinds = field dotnet_naming_style.instance_field_style.capitalization = camel_case dotnet_naming_style.instance_field_style.required_prefix = _ # Locals and parameters are camelCase dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local dotnet_naming_style.camel_case_style.capitalization = camel_case # Local functions are PascalCase dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style dotnet_naming_symbols.local_functions.applicable_kinds = local_function dotnet_naming_style.local_function_style.capitalization = pascal_case # By default, name items with PascalCase dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style dotnet_naming_symbols.all_members.applicable_kinds = * dotnet_naming_style.pascal_case_style.capitalization = pascal_case ############################### # C# Coding Conventions # ############################### [*.cs] # var preferences csharp_style_var_for_built_in_types = true:silent csharp_style_var_when_type_is_apparent = true:silent csharp_style_var_elsewhere = true:silent # Expression-bodied members csharp_style_expression_bodied_methods = false:silent csharp_style_expression_bodied_constructors = false:silent csharp_style_expression_bodied_operators = false:silent csharp_style_expression_bodied_properties = true:silent csharp_style_expression_bodied_indexers = true:silent csharp_style_expression_bodied_accessors = true:silent # Pattern matching preferences csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion csharp_style_pattern_matching_over_as_with_null_check = true:suggestion # Null-checking preferences csharp_style_throw_expression = true:suggestion csharp_style_conditional_delegate_call = true:suggestion # Modifier preferences csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion # Expression-level preferences csharp_prefer_braces = true:silent csharp_style_deconstructed_variable_declaration = true:suggestion csharp_prefer_simple_default_expression = true:suggestion csharp_style_pattern_local_over_anonymous_function = true:suggestion csharp_style_inlined_variable_declaration = true:suggestion ############################### # C# Formatting Rules # ############################### # New line preferences csharp_new_line_before_open_brace = all csharp_new_line_before_else = true csharp_new_line_before_catch = true csharp_new_line_before_finally = true csharp_new_line_before_members_in_object_initializers = true csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_between_query_expression_clauses = true # Indentation preferences csharp_indent_case_contents = true csharp_indent_switch_labels = true csharp_indent_labels = flush_left # Space preferences csharp_space_after_cast = false csharp_space_after_keywords_in_control_flow_statements = true csharp_space_between_method_call_parameter_list_parentheses = false csharp_space_between_method_declaration_parameter_list_parentheses = false csharp_space_between_parentheses = false csharp_space_before_colon_in_inheritance_clause = true csharp_space_after_colon_in_inheritance_clause = true csharp_space_around_binary_operators = before_and_after csharp_space_between_method_declaration_empty_parameter_list_parentheses = false csharp_space_between_method_call_name_and_opening_parenthesis = false csharp_space_between_method_call_empty_parameter_list_parentheses = false # Wrapping preferences csharp_preserve_single_line_statements = true csharp_preserve_single_line_blocks = true ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: "Bug report" description: "Create a report to help us improve" labels: [bug] body: - 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: Operating system placeholder: Debian 11, Windows 11, etc. validations: required: true - type: input attributes: label: Jellyfin installation method placeholder: Docker, Windows installer, etc. validations: required: true - type: textarea attributes: label: Support Bundle placeholder: go to Dashboard -> Plugins -> Media Analyzer -> Support Bundle (at the bottom of the page) and paste the contents of the textbox here validations: required: true - type: textarea attributes: label: Jellyfin logs placeholder: Paste any relevant logs here render: shell ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project labels: enhancement assignees: '' --- **Describe the feature you'd like added** A clear and concise description of what you would like to see added. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/dependabot.yml ================================================ 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 ================================================ FILE: .github/workflows/build.yml ================================================ name: "Build Plugin" on: push: branches: ["master", "analyzers"] pull_request: branches: ["master"] jobs: build: 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 }}" > Jellyfin.Plugin.MediaAnalyzer/Configuration/version.txt - name: Build run: dotnet build --no-restore - name: Upload artifact uses: actions/upload-artifact@v4 with: name: Jellyfin.Plugin.MediaAnalyzer-${{ github.sha }}.dll path: Jellyfin.Plugin.MediaAnalyzer/bin/Debug/net8.0/Jellyfin.Plugin.MediaAnalyzer.dll if-no-files-found: error ================================================ FILE: .github/workflows/package.sh ================================================ #!/bin/bash # Check argument count if [[ $# -ne 1 ]]; then echo "Usage: $0 VERSION" exit 1 fi # Use provided tag to derive archive filename and short tag version="$1" zip="jellyfin-plugin-mediaanalyzer-$version.zip" short="$(echo "$version" | sed "s/^v//")" # Get the assembly version CSPROJ="Jellyfin.Plugin.MediaAnalyzer/Jellyfin.Plugin.MediaAnalyzer.csproj" assemblyVersion="$(grep -m1 -oE "([0-9]\.){3}[0-9]" "$CSPROJ")" # Get the date date="$(date --utc -Iseconds | sed "s/\+00:00/Z/")" # Debug echo "Version: $version ($short)" echo "Archive: $zip" echo echo "Running unit tests" dotnet test -p:DefineConstants=SKIP_FFMPEG_TESTS || exit 1 echo echo "Building plugin in Release mode" dotnet build -c Release || exit 1 echo # Create packaging directory mkdir package cd package || exit 1 # Copy the freshly built plugin DLL to the packaging directory and archive cp "../Jellyfin.Plugin.MediaAnalyzer/bin/Release/net8.0/Jellyfin.Plugin.MediaAnalyzer.dll" ./ || exit 1 zip "$zip" Jellyfin.Plugin.MediaAnalyzer.dll || exit 1 # Calculate the checksum of the archive checksum="$(md5sum "$zip" | cut -f 1 -d " ")" # Generate the manifest entry for this plugin cat > manifest.json <<'EOF' { "version": "ASSEMBLY", "changelog": "- See the full changelog at [GitHub](https://github.com/Endrl/jellyfin-plugin-media-analyzer/blob/master/CHANGELOG.md)\n", "targetAbi": "10.9.0.0", "sourceUrl": "https://github.com/Endrl/jellyfin-plugin-media-analyzer/releases/download/VERSION/ZIP", "checksum": "CHECKSUM", "timestamp": "DATE" } EOF sed -i "s/ASSEMBLY/$assemblyVersion/" manifest.json sed -i "s/VERSION/$version/" manifest.json sed -i "s/ZIP/$zip/" manifest.json sed -i "s/CHECKSUM/$checksum/" manifest.json sed -i "s/DATE/$date/" manifest.json ================================================ FILE: .github/workflows/publish.yml ================================================ name: "Package plugin" on: workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: # set fetch-depth to 0 in order to clone all tags instead of just the current commit - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Checkout latest tag id: tag run: | tag="$(git tag --sort=committerdate | tail -n 1)" git checkout "$tag" echo "tag=$tag" >> $GITHUB_OUTPUT - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore - name: Package run: .github/workflows/package.sh ${{ steps.tag.outputs.tag }} - name: Upload plugin archive uses: actions/upload-artifact@v4 with: name: jellyfin-plugin-mediaanalyzer-bundle-${{ steps.tag.outputs.tag }}.zip path: | package/*.zip package/*.json if-no-files-found: error ================================================ FILE: .gitignore ================================================ bin/ obj/ BenchmarkDotNet.Artifacts/ /package/ # Ignore pre compiled web interface docker/dist ================================================ FILE: .vscode/launch.json ================================================ { // Paths and plugin names are configured in settings.json "version": "0.2.0", "configurations": [ { "type": "coreclr", "name": "Launch", "request": "launch", "preLaunchTask": "build-and-copy", "program": "${config:jellyfinDir}/bin/Debug/net8.0/jellyfin.dll", "args": [ //"--nowebclient" "--webdir", "${config:jellyfinWebDir}/dist/" ], "cwd": "${config:jellyfinDir}", } ] } ================================================ FILE: .vscode/settings.json ================================================ { // jellyfinDir : The directory of the cloned jellyfin server project // This needs to be built once before it can be used "jellyfinDir": "${workspaceFolder}/../jellyfin/Jellyfin.Server", // jellyfinWebDir : The directory of the cloned jellyfin-web project // This needs to be built once before it can be used "jellyfinWebDir": "${workspaceFolder}/../jellyfin-web", // jellyfinDataDir : the root data directory for a running jellyfin instance // This is where jellyfin stores its configs, plugins, metadata etc // This is platform specific by default, but on Windows defaults to // ${env:LOCALAPPDATA}/jellyfin "jellyfinDataDir": "${env:LOCALAPPDATA}/jellyfin", "jellyfinDataDirWin": "${env:LOCALAPPDATA}\\jellyfin", // The name of the plugin "pluginName": "Jellyfin.Plugin.MediaAnalyzer", "cmake.configureOnOpen": false } ================================================ FILE: .vscode/tasks.json ================================================ { // Paths and plugin name are configured in settings.json "version": "2.0.0", "tasks": [ { // A chain task - build the plugin, then copy it to your // jellyfin server's plugin directory "label": "build-and-copy", "dependsOrder": "sequence", "dependsOn": ["build","copy-dll-win"], "windows": { "dependsOn": ["build","copy-dll-win"] } }, { // Build the plugin "label": "build", "command": "dotnet", "type": "shell", "args": [ "publish", "${workspaceFolder}/${config:pluginName}.sln", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], "group": "build", "presentation": { "reveal": "silent" }, "problemMatcher": "$msCompile" }, { // Ensure the plugin directory exists before trying to use it "label": "make-plugin-dir", "type": "shell", "command": "mkdir", "args": [ "-Force", "-Path", "${config:jellyfinDataDir}/plugins/${config:pluginName}/" ] }, { // Copy the plugin dll to the jellyfin plugin install path // This command copies every .dll from the build directory to the plugin dir // Usually, you probablly only need ${config:pluginName}.dll // But some plugins may bundle extra requirements "label": "copy-dll-win", "type": "shell", "command": "xcopy", "args": [ "/I", "/y", ".\\${config:pluginName}\\bin\\Debug\\net8.0\\publish", "${config:jellyfinDataDirWin}\\plugins\\${config:pluginName}\\" ] }, { // Copy the plugin dll to the jellyfin plugin install path // This command copies every .dll from the build directory to the plugin dir // Usually, you probablly only need ${config:pluginName}.dll // But some plugins may bundle extra requirements "label": "copy-dll", "type": "shell", "command": "cp", "args": [ "./${config:pluginName}/bin/Debug/net8.0/publish/*", "${config:jellyfinDataDir}/plugins/${config:pluginName}/" ] }, ] } ================================================ FILE: ACKNOWLEDGEMENTS.md ================================================ 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) ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## Unreleased ### Changed * Task Timer is no longer configured by deafult. We listen for MediaLibrary changes instead. (Change back in options) ### Fixed * Prevents a crash during save when start >= end time ## v0.4.0.0 (2023-11-02) * Remove creatorId (sync with server implementation) ## v0.3.0.0 (2023-09-25) * Add options to control listener * Round to two decimal places * Improve log messages ## v0.2.0.0 (2023-05-28) * Blacklisting with db * Enable movies credits detection ## v0.1.0.0 (2023-05-04) * Outdated, removed from repository! * Initial release * New features * 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 * Internal changes * Move Chromaprint analysis code out of the episode analysis task * Add support for multiple analysis techinques * Breaking Change * Removed all server and frontend influencing mods * Removed EDL handling ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Analyzers/BlackFrameAnalyzer.cs ================================================ namespace Jellyfin.Plugin.MediaAnalyzer; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Microsoft.Extensions.Logging; /// /// 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; /// /// Initializes a new instance of the class. /// /// Logger. public BlackFrameAnalyzer(ILogger logger) { _logger = logger; } /// public async Task<(ReadOnlyCollection NotAnalyzed, ReadOnlyDictionary Analyzed, ReadOnlyDictionary SegmentMetadata)> AnalyzeMediaFilesAsync( ReadOnlyCollection analysisQueue, MediaSegmentType mode, CancellationToken cancellationToken) { if (mode != MediaSegmentType.Outro) { throw new NotImplementedException("Blackframe analyzing is just suitable for Credits/Outro"); } var creditTimes = new Dictionary(); var metadata = new Dictionary(); foreach (var episode in analysisQueue) { if (cancellationToken.IsCancellationRequested) { break; } var meta = await Plugin.Instance!.GetMetadataDb().GetSegments(episode.ItemId, mode, AnalyzerType.BlackFrameAnalyzer); var intro = AnalyzeMediaFile( episode, mode, Plugin.Instance!.Configuration.BlackFrameMinimumPercentage); if (intro is null) { continue; } // protect against broken timestamps if (intro.Start >= intro.End) { continue; } creditTimes[episode.ItemId] = intro; if (meta is null) { metadata[episode.ItemId] = new SegmentMetadata(episode, mode, AnalyzerType.BlackFrameAnalyzer); } } return (analysisQueue .Where(x => !creditTimes.ContainsKey(x.ItemId)) .ToList() .AsReadOnly(), creditTimes.AsReadOnly(), metadata.AsReadOnly()); } /// /// Analyzes an individual media file. Only public because of unit tests. /// /// Media file to analyze. /// Analysis mode. /// Percentage of the frame that must be black. /// Credits timestamp. public Segment? AnalyzeMediaFile(QueuedMedia episode, MediaSegmentType mode, int minimum) { var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration(); // Start by analyzing the last N minutes of the file. var start = TimeSpan.FromSeconds(config.MaximumEpisodeCreditsDuration); var end = TimeSpan.FromSeconds(config.MinimumCreditsDuration); 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; } else { // Some black frames were found, slide the range closer to the start end = midpoint; firstFrameTime = frames[0].Time + scanTime; } } if (firstFrameTime > 0) { return new(episode.ItemId, episode.IsEpisode(), new TimeRange(firstFrameTime, episode.Duration)); } return null; } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Analyzers/ChapterAnalyzer.cs ================================================ namespace Jellyfin.Plugin.MediaAnalyzer; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; /// /// 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 async Task<(ReadOnlyCollection NotAnalyzed, ReadOnlyDictionary Analyzed, ReadOnlyDictionary SegmentMetadata)> AnalyzeMediaFilesAsync( ReadOnlyCollection analysisQueue, MediaSegmentType mode, CancellationToken cancellationToken) { var skippableRanges = new Dictionary(); var metadata = new Dictionary(); var expression = mode == MediaSegmentType.Intro ? Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern : Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern; if (string.IsNullOrWhiteSpace(expression)) { return (analysisQueue, skippableRanges.AsReadOnly(), metadata.AsReadOnly()); } foreach (var episode in analysisQueue) { if (cancellationToken.IsCancellationRequested) { break; } var meta = await Plugin.Instance!.GetMetadataDb().GetSegments(episode.ItemId, mode, AnalyzerType.ChapterAnalyzer); var skipRange = FindMatchingChapter( episode, new(Plugin.Instance!.GetChapters(episode.ItemId)), expression, mode); if (skipRange is null) { continue; } // protect against broken timestamps if (skipRange.Start >= skipRange.End) { continue; } skippableRanges.Add(episode.ItemId, skipRange); if (meta is null) { metadata[episode.ItemId] = new SegmentMetadata(episode, mode, AnalyzerType.ChapterAnalyzer); } } return (analysisQueue .Where(x => !skippableRanges.ContainsKey(x.ItemId)) .ToList() .AsReadOnly(), skippableRanges.AsReadOnly(), metadata.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 Segment? FindMatchingChapter( QueuedMedia episode, Collection chapters, string expression, MediaSegmentType mode) { Segment? matchingChapter = null; var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration(); var minDuration = config.MinimumIntroDuration; int maxDuration = mode == MediaSegmentType.Intro ? config.MaximumIntroDuration : config.MaximumEpisodeCreditsDuration; if (mode == MediaSegmentType.Outro) { // 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 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; } matchingChapter = new(episode.ItemId, episode.IsEpisode(), currentRange); _logger.LogTrace("{Base}: okay", baseMessage); break; } return matchingChapter; } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Analyzers/ChromaprintAnalyzer.cs ================================================ namespace Jellyfin.Plugin.MediaAnalyzer; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Numerics; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Microsoft.Extensions.Logging; /// /// 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.128; private int minimumIntroDuration; private int maximumDifferences; private int invertedIndexShift; private double maximumTimeSkip; private double silenceDetectionMinimumDuration; private ILogger _logger; private MediaSegmentType _analyzingType; /// /// Initializes a new instance of the class. /// /// Logger. public ChromaprintAnalyzer(ILogger logger) { var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration(); maximumDifferences = config.MaximumFingerprintPointDifferences; invertedIndexShift = config.InvertedIndexShift; maximumTimeSkip = config.MaximumTimeSkip; silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration; minimumIntroDuration = config.MinimumIntroDuration; _logger = logger; } /// public async Task<(ReadOnlyCollection NotAnalyzed, ReadOnlyDictionary Analyzed, ReadOnlyDictionary SegmentMetadata)> AnalyzeMediaFilesAsync( ReadOnlyCollection analysisQueue, MediaSegmentType mode, CancellationToken cancellationToken) { // All segments for this season. var seasonSegments = new Dictionary(); // Cache of all fingerprints for this season. var fingerprintCache = new Dictionary(); // Episode analysis queue based on not analyzed episodes var episodeAnalysisQueue = analysisQueue.ToList().Where(m => !m.IsAnalyzed).ToList(); // Episodes that were analyzed and do not have an introduction. var episodesWithoutSegments = new List(); var metadata = new Dictionary(); this._analyzingType = mode; // we need at least two episodes, it's possible to use an already analzyed one as reference if (episodeAnalysisQueue.Count == 1 && analysisQueue.Count > 1) { var item = analysisQueue.Where(i => !episodeAnalysisQueue.Contains(i)).FirstOrDefault(); if (item is not null) { episodeAnalysisQueue.Add(item); } } // If we have just one episode, we can abort and flag the episode to skip blacklisting if (episodeAnalysisQueue.Count == 1) { var item = episodeAnalysisQueue.First(); _logger.LogInformation("Found just one episode for {Series}: S{Season}. Skipping as we need at least two.", item.SeriesName, item.SeasonNumber); item.SkipPreventAnalyzing = true; episodesWithoutSegments.Add(item); return (episodesWithoutSegments.AsReadOnly(), seasonSegments.AsReadOnly(), metadata.AsReadOnly()); } // Compute fingerprints for all episodes in the season foreach (var episode in episodeAnalysisQueue) { try { fingerprintCache[episode.ItemId] = FFmpegWrapper.Fingerprint(episode, mode); if (cancellationToken.IsCancellationRequested) { return (analysisQueue, seasonSegments.AsReadOnly(), metadata.AsReadOnly()); } } catch (FingerprintException ex) { _logger.LogDebug("Caught fingerprint error: {Ex}", ex); WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint); // Fallback to an empty fingerprint on any error fingerprintCache[episode.ItemId] = 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.ItemId, fingerprintCache[currentEpisode.ItemId], remainingEpisode.ItemId, fingerprintCache[remainingEpisode.ItemId]); // 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, add the starting time of the fingerprint to the reported time range. */ if (this._analyzingType == MediaSegmentType.Outro) { currentIntro.Start += currentEpisode.CreditsFingerprintStart; currentIntro.End += currentEpisode.CreditsFingerprintStart; remainingIntro.Start += remainingEpisode.CreditsFingerprintStart; remainingIntro.End += remainingEpisode.CreditsFingerprintStart; } // Only save the discovered intro if it is: // - the first intro discovered for this episode // - longer than the previously discovered intro if ( !seasonSegments.TryGetValue(currentIntro.ItemId, out var savedCurrentIntro) || currentIntro.Duration > savedCurrentIntro.Duration) { if (ValidateTime(currentIntro)) { var meta = await Plugin.Instance!.GetMetadataDb().GetSegments(currentEpisode.ItemId, mode, AnalyzerType.ChromaprintAnalyzer); if (meta is null) { metadata[currentIntro.ItemId] = new SegmentMetadata(currentEpisode, mode, AnalyzerType.ChromaprintAnalyzer); } seasonSegments[currentIntro.ItemId] = currentIntro; } } if ( !seasonSegments.TryGetValue(remainingIntro.ItemId, out var savedRemainingIntro) || remainingIntro.Duration > savedRemainingIntro.Duration) { if (ValidateTime(currentIntro)) { var meta = await Plugin.Instance!.GetMetadataDb().GetSegments(remainingEpisode.ItemId, mode, AnalyzerType.ChromaprintAnalyzer); if (meta is null) { metadata[remainingIntro.ItemId] = new SegmentMetadata(remainingEpisode, mode, AnalyzerType.ChromaprintAnalyzer); } seasonSegments[remainingIntro.ItemId] = remainingIntro; } } break; } // If no intro is found at this point, the popped episode is not reinserted into the queue. episodesWithoutSegments.Add(currentEpisode); } if (this._analyzingType == MediaSegmentType.Intro) { // Adjust all introduction end times so that they end at silence. seasonSegments = AdjustIntroEndTimes(analysisQueue, seasonSegments); } // If cancellation was requested, report that no episodes were analyzed. if (cancellationToken.IsCancellationRequested) { seasonSegments.Clear(); metadata.Clear(); return (analysisQueue, seasonSegments.AsReadOnly(), metadata.AsReadOnly()); } return (episodesWithoutSegments.AsReadOnly(), seasonSegments.AsReadOnly(), metadata.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 (Segment Lhs, Segment Rhs) CompareEpisodes( Guid lhsId, uint[] lhsPoints, Guid rhsId, uint[] rhsPoints) { // Creates an inverted fingerprint point index for both episodes. // For every point which is a 100% match, search for an introduction at that point. var (lhsRanges, rhsRanges) = SearchInvertedIndex(lhsId, lhsPoints, rhsId, rhsPoints); if (lhsRanges.Count > 0) { _logger.LogTrace("Index search successful"); return GetLongestTimeRange(lhsId, lhsRanges, rhsId, rhsRanges); } _logger.LogTrace( "Unable to find a shared introduction sequence between {LHS} and {RHS}", lhsId, rhsId); return (new Segment(lhsId), new Segment(rhsId)); } /// /// 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 (Segment Lhs, Segment 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 Segment(lhsId, lhsIntro), new Segment(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); var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints); 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 int modifiedPointValue)) { var lhsFirst = (int)lhsIndex[originalPoint]; var rhsFirst = modifiedPointValue; 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._analyzingType == MediaSegmentType.Intro) { // 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) { // The minimum duration of audio that must be silent before adjusting the intro's end. var minimumSilence = Plugin.Instance!.Configuration.SilenceDetectionMinimumDuration; Dictionary modifiedIntros = new(); // For all episodes foreach (var episode in episodes) { _logger.LogTrace( "Adjusting introduction end time for {Name} ({Id})", episode.Name, episode.ItemId); // If no intro was found for this episode, skip it. if (!originalIntros.TryGetValue(episode.ItemId, 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.End - 15, originalIntro.End); _logger.LogTrace( "{Name} original intro: {Start} - {End}", episode.Name, originalIntro.Start, originalIntro.End); // Detect silence in the media file up to the end of the intro. var silence = FFmpegWrapper.DetectSilence(episode, (int)originalIntro.End + 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.Start) { continue; } // Adjust the end timestamp of the intro to match the start of the silence region. originalIntro.End = currentRange.Start; break; } _logger.LogTrace( "{Name} adjusted intro: {Start} - {End}", episode.Name, originalIntro.Start, originalIntro.End); // Add the (potentially) modified intro back. modifiedIntros[episode.ItemId] = 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); } /// /// Be sure the segment have a valid time. /// /// Segment. /// True is valid. private bool ValidateTime(Segment seg) { return seg.End > seg.Start; } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Analyzers/IMediaFileAnalyzer.cs ================================================ namespace Jellyfin.Plugin.MediaAnalyzer; using System; using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; /// /// Media file analyzer interface. /// public interface IMediaFileAnalyzer { /// /// Analyze media files for shared introductions or credits, returning all media files that were not analyzed, analyzed and optional metadata for both. /// /// Collection of unanalyzed media files. /// Analysis mode. /// Cancellation token from scheduled task. /// Collection of media files that were **unsuccessfully analyzed** and successfull. public Task<(ReadOnlyCollection NotAnalyzed, ReadOnlyDictionary Analyzed, ReadOnlyDictionary SegmentMetadata)> AnalyzeMediaFilesAsync( ReadOnlyCollection analysisQueue, MediaSegmentType mode, CancellationToken cancellationToken); } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Configuration/PluginConfiguration.cs ================================================ using MediaBrowser.Model.Plugins; namespace Jellyfin.Plugin.MediaAnalyzer.Configuration; /// /// Plugin configuration. /// public class PluginConfiguration : BasePluginConfiguration { /// /// Initializes a new instance of the class. /// public PluginConfiguration() { } // ===== General settings ===== /// /// Gets or sets a value indicating whether we run after a library scan. /// public bool RunAfterLibraryScan { get; set; } = true; /// /// Gets or sets a value indicating whether we run after library item added or updated events. /// public bool RunAfterAddOrUpdateEvent { get; set; } = true; // ===== Analysis settings ===== /// /// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem. /// public bool CacheFingerprints { get; set; } = false; /// /// Gets or sets a value indicating whether the Blacklist should be resetted. /// public bool ResetBlacklist { get; set; } = false; /// /// Gets or sets a value indicating whether the Blacklist should be created or used. /// public bool EnableBlacklist { get; set; } = true; /// /// 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 or sets the comma separated list of tv shows and seasons to skip the analyze. Format: "My Show;S01;S02, Another Show". /// public string SkippedTvShows { get; set; } = string.Empty; /// /// Gets or sets the comma separated list of movies to skip the analyze.". /// public string SkippedMovies { get; set; } = string.Empty; /// /// Gets or sets a value indicating whether to analyze season 0. /// public bool AnalyzeSeasonZero { get; set; } = false; // ===== Custom analysis settings ===== /// /// Gets or sets the percentage of each episode's audio track to analyze. /// public int AnalysisPercent { get; set; } = 30; /// /// 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; } = 15; /// /// 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 MaximumEpisodeCreditsDuration { get; set; } = 240; /// /// Gets or sets the upper limit (in seconds) on the length of each movie's audio track that will be analyzed when searching for ending credits. /// public int MaximumMovieCreditsDuration { get; set; } = 900; /// /// 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?|Ending|End|Outro)(\s|$)"; // ===== 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; } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Configuration/configPage.html ================================================
General
Whenever the media library scan task ends, run the analyzer.
When media folder watch is enabled the library will push events we can also listen for.
Analysis
Analyze show extras (specials), listed as season 0.
Requires lot's of disk space. Should be just enabled for development or when you play around with intro/credits options (Also disable blacklisting!).
If we can't find an Intro/Outro Segment for an episode or movie it is blacklisted to prevent repeated analysis.
Delete the blacklist of segments.
Maximum degree of parallelism to use when analyzing.
Enter the names of libraries to analyze, separated by commas. If this field is left blank, all libraries on the server containing movies or television episodes will be analyzed.
Enter the names of tv shows to skip the analyze, separated by commas. You can also skip seasons. Format: "My Show;S01;S02, Another Show, Third Show;S03;S05"
Enter the names of Movies to skip the analyze, separated by commas. Format: "The Godfather, Spiderman, Matrix"
Modify introduction requirements
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.

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.

Modify Credits requirements
Sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
Sets the minimum length of similar audio that will be considered ending credits.
Sets the upper limit on the length of each episode that will be analyzed when searching for ending credits.
Sets the upper limit on the length of each movie that will be analyzed when searching for ending credits.
Silence detection options
Noise tolerance in negative decibels.
Minimum silence duration in seconds before adjusting introduction end time.

Support Bundle
Advanced

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.

Key Function
Up arrow Shift the left episode up by 0.128 seconds. Holding control will shift the episode by 10 seconds.
Down arrow Shift the left episode down by 0.128 seconds. Holding control will shift the episode by 10 seconds.
Right arrow Advance to the next pair of episodes.
Left arrow Go back to the previous pair of episodes.

Shift amount:
Suggested shifts:


================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Configuration/version.txt ================================================ unknown ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Configuration/visualizer.js ================================================ // 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; } 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); } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Controllers/MediaAnalyzerController.cs ================================================ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net.Mime; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.MediaAnalyzer.Controllers; /// /// PluginEdl controller. /// [Authorize(Policy = "RequiresElevation")] [ApiController] [Produces(MediaTypeNames.Application.Json)] [Route("PluginMediaAnalyzer")] public class MediaAnalyzerController : ControllerBase { private readonly ILoggerFactory _loggerFactory; private readonly ILibraryManager _libraryManager; private readonly IMediaSegmentManager _mediaSegmentManager; /// /// Initializes a new instance of the class. /// /// Logger factory. /// LibraryManager. /// MediaSegmentsManager. public MediaAnalyzerController( ILoggerFactory loggerFactory, ILibraryManager libraryManager, IMediaSegmentManager mediaSegmentManager) { _loggerFactory = loggerFactory; _libraryManager = libraryManager; _mediaSegmentManager = mediaSegmentManager; } /// /// Plugin meta endpoint. /// /// The version info. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public JsonResult GetPluginMetadata() { var json = new { version = Plugin.Instance!.Version.ToString(3), }; return new JsonResult(json); } /// /// Run analyzer based on itemIds and params and returns the segments + metadata. /// /// ItemIds. /// Analyzers to use. /// Segment Type to search for. /// The found segments. [HttpGet("Analyzers")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task AnalyzeIds( [FromQuery, Required] Guid[] itemIds, [FromQuery, Required] AnalyzerType[] analyzerTypes, [FromQuery, Required] MediaSegmentType mode) { var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager, MediaSegmentType.Intro); var errors = new JsonArray(); var analyzedItems = new Dictionary(); var metadatas = new Dictionary(); var jsonObject = new JsonObject(); // get ItemIds var mediaItems = queueManager.GetMediaItemsById(itemIds); // setup analyzers foreach (var (key, media) in mediaItems) { var items = media.AsReadOnly(); var totalItems = mediaItems.Count; var first = items[0]; var analyzers = new Collection(); if (analyzerTypes.Contains(AnalyzerType.ChapterAnalyzer)) { analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger())); } // Movies don't use chromparint analyzer if (first.IsEpisode() && analyzerTypes.Contains(AnalyzerType.ChromaprintAnalyzer)) { if (items.Count == 1) { errors.Add($"Chromaprint needs at least two media files to compare, one provided: {first.GetFullName}"); } else { analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger())); } } if (mode == MediaSegmentType.Outro && analyzerTypes.Contains(AnalyzerType.BlackFrameAnalyzer)) { analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger())); } // Use each analyzer to find skippable ranges in all media files, removing successfully // analyzed items from the queue. foreach (var analyzer in analyzers) { var cancellationToken = default(CancellationToken); var (notAnalyzed, analyzed, metadata) = await analyzer.AnalyzeMediaFilesAsync(items, mode, cancellationToken); var atype = analyzer is BlackFrameAnalyzer ? "BlackFrameAnalyzer" : analyzer is ChromaprintAnalyzer ? "ChromaprintAnalyzer" : analyzer is ChapterAnalyzer ? "ChapterAnalyzer" : throw new NotImplementedException("Unknown Analyzer type"); jsonObject.Add(atype, BuildAnalyzerOutput(analyzed, metadata)); } } jsonObject.Add("Errors", errors); return new JsonResult(jsonObject); } /// /// Fingerprint the provided episode and returns the uncompressed fingerprint data points. /// /// Episode id. /// Type Intro or Outro. /// Read only collection of fingerprint points. [HttpGet("Chromaprint/{Id}")] public ActionResult GetMediaFingerprint( [FromRoute, Required] Guid id, [FromQuery, Required] MediaSegmentType mode) { var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager, mode); var queuedMedia = queueManager.GetMediaItemsById([id]); // Search through all queued episodes to find the requested id foreach (var season in queuedMedia) { foreach (var needle in season.Value) { if (needle.ItemId == id) { return FFmpegWrapper.Fingerprint(needle, mode); } } } return NotFound(); } private static JsonObject BuildAnalyzerOutput(ReadOnlyDictionary segments, ReadOnlyDictionary metadatas) { var itemsObject = new JsonObject(); Dictionary metadataLocal = metadatas.ToDictionary(); foreach (var item in segments) { if (metadatas.TryGetValue(item.Key, out var metadata)) { metadataLocal.Remove(item.Key); var json = new { Segment = item.Value, Metadata = metadata }; itemsObject.Add(item.Key.ToString(), json.ToString()); } } // we may have more metadata foreach (var item in metadataLocal) { var json = new { Metadata = item.Value }; itemsObject.Add(item.Key.ToString(), json.ToString()); } return itemsObject; } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Controllers/TroubleshootingController.cs ================================================ using System.Net.Mime; using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.MediaAnalyzer.Controllers; /// /// Troubleshooting controller. /// [Authorize(Policy = "RequiresElevation")] [ApiController] [Produces(MediaTypeNames.Application.Json)] [Route("JellyfinPluginIntroSkipSupport")] public class TroubleshootingController : ControllerBase { // private readonly IApplicationHost _applicationHost; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// Logger. public TroubleshootingController( ILogger logger) { _logger = logger; } /// /// Gets a Markdown formatted support bundle. /// /// Support bundle created. /// Support bundle. [HttpGet("SupportBundle")] [Produces(MediaTypeNames.Text.Plain)] public ActionResult GetSupportBundle() { var bundle = new StringBuilder(); // bundle.Append("* Jellyfin version: "); // bundle.Append(_applicationHost.ApplicationVersionString); // bundle.Append('\n'); var version = Plugin.Instance!.Version.ToString(3); bundle.Append("* Plugin version: "); bundle.Append(version); bundle.Append('\n'); bundle.Append("* Warnings: `"); bundle.Append(WarningManager.GetWarnings()); bundle.Append("`\n"); bundle.Append(FFmpegWrapper.GetChromaprintLogs()); return bundle.ToString(); } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Controllers/VisualizationController.cs ================================================ using System; using System.Collections.Generic; using System.Globalization; using System.Net.Mime; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.MediaAnalyzer.Controllers; /// /// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis. /// [Authorize(Policy = "RequiresElevation")] [ApiController] [Produces(MediaTypeNames.Application.Json)] [Route("JellyfinPluginIntroSkip")] public class VisualizationController : ControllerBase { private readonly ILoggerFactory _loggerFactory; private readonly ILibraryManager _libraryManager; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// Logger. /// loggerFactory. /// libraryManager. public VisualizationController( ILogger logger, ILoggerFactory loggerFactory, ILibraryManager libraryManager) { _loggerFactory = loggerFactory; _libraryManager = libraryManager; _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>(); var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager, MediaSegmentType.Intro); var queuedMedia = queueManager.GetMediaItems(); // Loop through all seasons in the analysis queue foreach (var kvp in queuedMedia) { // 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.ItemId, 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) { var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager, MediaSegmentType.Intro); var queuedMedia = queueManager.GetMediaItems(); // Search through all queued episodes to find the requested id foreach (var season in queuedMedia) { foreach (var needle in season.Value) { if (needle.ItemId == id) { return FFmpegWrapper.Fingerprint(needle, MediaSegmentType.Intro); } } } return NotFound(); } private string GetSeasonName(QueuedMedia 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) { var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager, MediaSegmentType.Intro); var queuedMedia = queueManager.GetMediaItems(); foreach (var queuedEpisodes in queuedMedia) { 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; } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Data/AnalyzerType.cs ================================================ namespace Jellyfin.Plugin.MediaAnalyzer; /// /// Analyzer Type. /// public enum AnalyzerType { /// /// No Analyzer. /// NotSet, /// /// Blackframe Analyzer. /// BlackFrameAnalyzer, /// /// Chapter Analyzer. /// ChapterAnalyzer, /// /// Chromaprint Analyzer. /// ChromaprintAnalyzer, } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Data/BlackFrame.cs ================================================ namespace Jellyfin.Plugin.MediaAnalyzer; /// /// 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; } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Data/EpisodeVisualization.cs ================================================ using System; namespace Jellyfin.Plugin.MediaAnalyzer; /// /// 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; } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Data/FingerprintException.cs ================================================ using System; namespace Jellyfin.Plugin.MediaAnalyzer; /// /// 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) { } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Data/IntroWithMetadata.cs ================================================ using System; using System.Text.Json.Serialization; namespace Jellyfin.Plugin.MediaAnalyzer; /// /// An Segment class with episode metadata. Only used in end to end testing programs. /// public class IntroWithMetadata : Segment { /// /// Initializes a new instance of the class. /// /// Series name. /// Season number. /// Episode title. /// Intro timestamps. public IntroWithMetadata(string series, int season, string title, Segment intro) { Series = series; Season = season; Title = title; ItemId = intro.ItemId; Start = intro.Start; End = intro.End; } /// /// 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; } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Data/MediaSegmentsDb.cs ================================================ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller; using MediaBrowser.Model.MediaSegments; namespace Jellyfin.Plugin.MediaAnalyzer; /// /// Small abstraction over MediaSegmentsManager. /// public class MediaSegmentsDb { private readonly IMediaSegmentManager _segmentsManager; /// /// Initializes a new instance of the class. /// /// MediaSegmentsManager. public MediaSegmentsDb(IMediaSegmentManager segmentsManager) { _segmentsManager = segmentsManager; } /// /// Test if we can find segments. /// /// ItemId. /// Mode. /// A representing the result of the asynchronous operation. public async Task HasSegments(Guid itemId, MediaSegmentType type) { var list = await _segmentsManager.GetSegmentsAsync(itemId, [type]).ConfigureAwait(false); return list.Any(); } /// /// Create new media segment together with metadata. Can also handle additional metadata without segment. /// /// segments to add. /// Metadata for a segment. /// Mode. /// A representing the result of the asynchronous operation. public async Task CreateMediaSegments(ReadOnlyDictionary segments, ReadOnlyDictionary metadata, MediaSegmentType mode) { var metaDBb = Plugin.Instance!.GetMetadataDb(); Dictionary metadataLocal = metadata.ToDictionary(); if (metaDBb == null) { throw new InvalidOperationException("Meta database was null"); } foreach (var (key, seg) in segments) { var newGuid = Guid.NewGuid(); var newSeg = new MediaSegmentDto() { Id = newGuid, ItemId = seg.ItemId, Type = mode, StartTicks = Utils.SToTicks(seg.Start), EndTicks = Utils.SToTicks(seg.End), }; await _segmentsManager.CreateSegmentAsync(newSeg, Plugin.Instance!.Name).ConfigureAwait(false); if (metadata.TryGetValue(key, out var meta)) { metadataLocal.Remove(key); meta.SegmentId = newGuid; metaDBb.SaveSegment(meta); } } // we may have more metadata foreach (var meta in metadataLocal) { metaDBb.SaveSegment(meta.Value); } } /// /// Get segments from db by mode and id. /// /// Item Id. /// Mode of analysis. /// Dictionary of guid,segments. public async Task> GetMediaSegmentsByIdAsync(Guid itemId, MediaSegmentType mode) { var segments = await _segmentsManager.GetSegmentsAsync(itemId, [mode]).ConfigureAwait(false); var intros = new Dictionary(); foreach (var item in segments) { intros.TryAdd(item.ItemId, new Segment() { ItemId = item.ItemId, Start = Utils.TicksToS(item.StartTicks), End = Utils.TicksToS(item.EndTicks), }); } return intros; } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Data/QueuedMedia.cs ================================================ using System; namespace Jellyfin.Plugin.MediaAnalyzer; /// /// Media queued for analysis. /// public class QueuedMedia { /// /// 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 media id. /// public Guid ItemId { get; set; } /// /// Gets or sets a value indicating whether this media has been already analyzed. /// public bool IsAnalyzed { get; set; } /// /// Gets or sets a value indicating whether this media should be skipped for blacklisting. /// This will happen when a Season has just one episode, which can't be Chromaprint compared analyzed but maybe at a later run. /// public bool SkipPreventAnalyzing { get; set; } /// /// Gets or sets the full path to episode/movie. /// public string Path { get; set; } = string.Empty; /// /// Gets or sets the name of the media, episode or movie. /// public string Name { get; set; } = string.Empty; /// /// Gets or sets the name of the source like quality (1080p, 4k, ...). /// public string SourceName { 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; } /// public override bool Equals(object? obj) { return (obj as QueuedMedia)?.ItemId == this.ItemId; } /// public override int GetHashCode() { return ItemId.GetHashCode(); } /// /// Gets the full name of the media, episode or movie with source name/quality. /// /// The full name of the media. public string GetFullName() { if (IsEpisode()) { return $"{SeriesName} S{SeasonNumber} - {Name}"; } else { return $"{Name} ({SourceName})"; } } /// /// Gets or sets a value indicating whether this media is an episode, part of a tv show. /// /// Is an episode or not. public bool IsEpisode() { return !string.IsNullOrEmpty(SeriesName); } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Data/Segment.cs ================================================ using System; using System.Text.Json.Serialization; namespace Jellyfin.Plugin.MediaAnalyzer; /// /// 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 Segment { /// /// Initializes a new instance of the class. /// /// Episode. /// is episode. /// Introduction time range. public Segment(Guid episode, bool isEpisode, TimeRange intro) { ItemId = episode; Start = intro.Start; End = intro.End; IsEpisode = isEpisode; } /// /// Initializes a new instance of the class. /// /// Episode. /// Introduction time range. public Segment(Guid episode, TimeRange intro) { ItemId = episode; Start = intro.Start; End = intro.End; } /// /// Initializes a new instance of the class. /// /// Episode. public Segment(Guid episode) { ItemId = episode; Start = 0; End = 0; } /// /// Initializes a new instance of the class. /// /// intro. public Segment(Segment intro) { ItemId = intro.ItemId; Start = intro.Start; End = intro.End; } /// /// Initializes a new instance of the class. /// public Segment() { } /// /// Gets or sets the item ID of db. /// public Guid ItemId { 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 => End > 0; /// /// Gets the duration of this intro. /// [JsonIgnore] public double Duration => End - Start; /// /// Gets or sets the segment sequence start time. /// public double Start { get; set; } /// /// Gets or sets the segment sequence end time. /// public double End { get; set; } /// /// Gets or sets a value indicating whether this is an episode (not a movie). /// public bool IsEpisode { get; set; } = true; /// /// Gets or sets which analyzer created this segment. /// public AnalyzerType? AnalyzerType { get; set; } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Data/TimeRange.cs ================================================ using System; namespace Jellyfin.Plugin.MediaAnalyzer; #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); } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Data/TimeRangeHelpers.cs ================================================ using System; using System.Collections.Generic; namespace Jellyfin.Plugin.MediaAnalyzer; #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; } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Data/WarningManager.cs ================================================ namespace Jellyfin.Plugin.MediaAnalyzer; using System; /// /// 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(); } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Db/MediaAnalyzerDbContext.cs ================================================ using System; using Microsoft.EntityFrameworkCore; namespace Jellyfin.Plugin.MediaAnalyzer; /// /// Plugin database. /// public class MediaAnalyzerDbContext : DbContext { private string dbPath; /// /// Initializes a new instance of the class. /// /// Path to db. public MediaAnalyzerDbContext(string path) { dbPath = path; } /// /// Initializes a new instance of the class. /// /// The options. public MediaAnalyzerDbContext(DbContextOptions options) : base(options) { var folder = Environment.SpecialFolder.LocalApplicationData; var path = Environment.GetFolderPath(folder); dbPath = System.IO.Path.Join(path, "mediaanalyzer.db"); } /// /// Gets the containing the blacklisted segments. /// public DbSet SegmentMetadata => Set(); /// protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseSqlite($"Data Source={dbPath}"); /// protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() .HasKey(s => s.Id); modelBuilder.Entity() .Property(s => s.Id) .ValueGeneratedOnAdd(); modelBuilder.Entity() .HasIndex(s => s.ItemId); } /// /// Apply migrations. Needs to be called before any actions are executed. /// public void ApplyMigrations() { this.Database.Migrate(); } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Db/MediaAnalyzerDbFactory.cs ================================================ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; namespace Jellyfin.Plugin.MediaAnalyzer; /// /// Plugin database factory. /// public class MediaAnalyzerDbFactory : IDesignTimeDbContextFactory { /// public MediaAnalyzerDbContext CreateDbContext(string[] args) { var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseSqlite("Data Source=jfpmediaanalyzer.db"); return new MediaAnalyzerDbContext(optionsBuilder.Options); } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Db/SegmentMetadata.cs ================================================ using System; using Jellyfin.Data.Enums; namespace Jellyfin.Plugin.MediaAnalyzer; /// /// Metadata for MediaSegments. Metadata is also created for non segments (e.g. analyze blocking). /// public class SegmentMetadata { /// /// Initializes a new instance of the class. /// /// Media item. /// The mode it ran. /// Analyzer who created it. public SegmentMetadata(QueuedMedia media, MediaSegmentType mode, AnalyzerType analyzer) { ItemId = media.ItemId; Type = mode; AnalyzerType = analyzer; Name = media.GetFullName(); } /// /// Initializes a new instance of the class. /// public SegmentMetadata() { } /// /// Gets or sets the Id. Database generated. /// public Guid Id { get; set; } /// /// Gets or sets the "full" name for the Media (Series + Season + Episode or Movie + Source). /// public string Name { get; set; } = string.Empty; /// /// Gets or sets the segment Id of Jellyfin. /// public Guid SegmentId { get; set; } /// /// Gets or sets the item ID of Jellyfin. /// public Guid ItemId { get; set; } /// /// Gets or sets the segment type. /// public MediaSegmentType Type { get; set; } /// /// Gets or sets a value indicating whether the segment is blocked for future analysis. /// public bool PreventAnalyzing { get; set; } /// /// Gets or sets which analyzer created this segment. /// public AnalyzerType AnalyzerType { get; set; } = AnalyzerType.NotSet; /// /// Gets or sets the analyzer note. Data that an analyzer might provide as additional info. /// public string AnalyzerNote { get; set; } = string.Empty; } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Db/SegmentMetadataDb.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using Microsoft.EntityFrameworkCore; namespace Jellyfin.Plugin.MediaAnalyzer; /// /// Database api for segment metadata. /// public class SegmentMetadataDb { private string _pluginDbPath; /// /// Initializes a new instance of the class. /// /// Plugin db path. public SegmentMetadataDb(string pluginDbPath) { _pluginDbPath = pluginDbPath; } /// /// Create or update a segment. /// /// Segment. public async void SaveSegment(SegmentMetadata seg) { using var db = GetPluginDb(); await CreateOrUpdate(db, seg).ConfigureAwait(false); await db.SaveChangesAsync().ConfigureAwait(false); } /// /// Create prevent analyze segments from QueuedMedia. /// /// Queued Media. /// Mode. /// A representing the asynchronous operation. public async Task CreatePreventAnalyzeSegments(IReadOnlyCollection media, MediaSegmentType mode) { using var db = GetPluginDb(); foreach (var seg in media) { var newseg = new SegmentMetadata { Name = seg.GetFullName(), Type = mode, PreventAnalyzing = true, ItemId = seg.ItemId, }; await CreateOrUpdate(db, newseg).ConfigureAwait(false); } await db.SaveChangesAsync().ConfigureAwait(false); } /// /// Get metadata for segmentId. /// /// Media ItemId. /// A representing the asynchronous operation. public async Task GetSegment(Guid segmentId) { using var db = GetPluginDb(); return await db.SegmentMetadata.AsNoTracking().FirstAsync(s => s.SegmentId == segmentId).ConfigureAwait(false); } /// /// Get metadata for itemId and type. We also store metadata for media that have no segmentId in jellyfin. /// /// Media ItemId. /// Segment Type. /// Optional: type of ananlyzer. /// A representing the asynchronous operation. public async Task> GetSegments(Guid itemId, MediaSegmentType type, AnalyzerType? analyzer) { using var db = GetPluginDb(); var query = db.SegmentMetadata.Where(s => s.ItemId == itemId && s.Type == type); if (analyzer is not null) { query = query.Where(s => s.AnalyzerType == analyzer); } return await query.AsNoTracking().ToListAsync().ConfigureAwait(false); } /// /// Check if itemId and type should be prevented to analyze. AnalyzerType of these segments is NotSet. /// /// Media ItemId. /// Segment Type. /// A representing the asynchronous operation. public async Task PreventAnalyze(Guid itemId, MediaSegmentType type) { using var db = GetPluginDb(); var seg = await db.SegmentMetadata.FirstAsync(s => s.ItemId == itemId && s.Type == type && s.AnalyzerType == AnalyzerType.NotSet).ConfigureAwait(false); // we may have multiple metadata for the same type+itemId. Search in all of them return seg is not null && seg.PreventAnalyzing; } /// /// Delete all metadata for itemId with prevent analyze set to true. Without itemId deletes them all. /// /// Media ItemId. /// A representing the asynchronous operation. public async Task DeletePreventAnalyzeSegments(Guid? itemId) { using var db = GetPluginDb(); var query = db.SegmentMetadata.Where(s => s.PreventAnalyzing); if (!itemId.IsNullOrEmpty()) { query = db.SegmentMetadata.Where(s => s.ItemId == itemId); } await query.ExecuteDeleteAsync().ConfigureAwait(false); } /// /// Delete all metadata for itemId and optional type. /// /// Media ItemId. /// Segment Type. /// A representing the asynchronous operation. public async Task DeleteSegments(Guid itemId, MediaSegmentType? type) { using var db = GetPluginDb(); var query = db.SegmentMetadata.Where(s => s.ItemId == itemId); if (type is not null) { query = query.Where(s => s.Type == type); } await query.ExecuteDeleteAsync().ConfigureAwait(false); } /// /// Create or update a segment. /// /// Database. /// SegmentMetadata. /// Task. private async Task CreateOrUpdate(MediaAnalyzerDbContext db, SegmentMetadata seg) { var found = await db.SegmentMetadata.FirstAsync(s => s.Id.Equals(seg.Id)).ConfigureAwait(false); if (found is not null) { found.Name = seg.Name; found.SegmentId = seg.SegmentId; found.Type = seg.Type; found.ItemId = seg.ItemId; found.PreventAnalyzing = seg.PreventAnalyzing; found.AnalyzerType = seg.AnalyzerType; found.AnalyzerNote = seg.AnalyzerNote; } else { db.SegmentMetadata.Add(seg); } } /// /// Get context of plugin database. /// /// Instance of db. public MediaAnalyzerDbContext GetPluginDb() { return new MediaAnalyzerDbContext(_pluginDbPath); } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/Entrypoint/LibraryChangedEntrypoint.cs ================================================ using System; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities.Movies; 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 Jellyfin.Plugin.MediaAnalyzer; /// /// Act on changes of the jellyfin library. /// public sealed class LibraryChangedEntrypoint : IHostedService, IDisposable { private readonly ILibraryManager _libraryManager; private readonly ITaskManager _taskManager; private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; private Timer _queueTimer; private bool _analyzeAgain; /// /// Initializes a new instance of the class. /// /// Library manager. /// Task manager. /// Logger. /// Logger factory. public LibraryChangedEntrypoint( ILibraryManager libraryManager, ITaskManager taskManager, ILogger logger, ILoggerFactory loggerFactory) { _libraryManager = libraryManager; _taskManager = taskManager; _logger = logger; _loggerFactory = loggerFactory; _queueTimer = new Timer( OnQueueTimerCallback, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); } /// public Task StartAsync(CancellationToken cancellationToken) { _libraryManager.ItemAdded += LibraryManagerItemAdded; _libraryManager.ItemUpdated += LibraryManagerItemUpdated; _libraryManager.ItemRemoved += LibraryManagerItemRemoved; FFmpegWrapper.Logger = _logger; return Task.CompletedTask; } /// public Task StopAsync(CancellationToken cancellationToken) { _libraryManager.ItemAdded -= LibraryManagerItemAdded; _libraryManager.ItemUpdated -= LibraryManagerItemUpdated; _libraryManager.ItemRemoved -= LibraryManagerItemRemoved; return Task.CompletedTask; } /// /// Delete segments for itemid when library removed it. /// /// The sending entity. /// The . private void LibraryManagerItemRemoved(object? sender, ItemChangeEventArgs itemChangeEventArgs) { if (itemChangeEventArgs.Item is not Movie and not Episode) { return; } if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual) { return; } _ = Plugin.Instance!.GetMetadataDb().DeleteSegments(itemChangeEventArgs.Item.Id, null); } /// /// Library item was added. /// /// The sending entity. /// The . private void LibraryManagerItemAdded(object? sender, ItemChangeEventArgs itemChangeEventArgs) { if (!Plugin.Instance!.Configuration.RunAfterAddOrUpdateEvent) { return; } // Don't do anything if it's not a supported media type if (itemChangeEventArgs.Item is not Movie and not Episode) { return; } if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual) { return; } StartTimer(); } /// /// TaskManager task ended. /// /// The sending entity. /// The . private void TaskManagerTaskCompleted(object? sender, TaskCompletionEventArgs eventArgs) { var result = eventArgs.Result; if (!Plugin.Instance!.Configuration.RunAfterLibraryScan) { return; } if (result.Key != "RefreshLibrary") { return; } if (result.Status != TaskCompletionStatus.Completed) { return; } StartTimer(); } /// /// Library item was updated. /// /// The sending entity. /// The . private void LibraryManagerItemUpdated(object? sender, ItemChangeEventArgs itemChangeEventArgs) { if (!Plugin.Instance!.Configuration.RunAfterAddOrUpdateEvent) { return; } // Don't do anything if it's not a supported media type if (itemChangeEventArgs.Item is not Movie and not Episode) { return; } if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual) { return; } StartTimer(); } /// /// Start or restart timer to debounce analyzing. /// private void StartTimer() { if (Plugin.Instance!.AnalysisRunning) { _analyzeAgain = true; } else { _logger.LogInformation("Media Library changed, analyzis will start soon!"); _queueTimer.Change(TimeSpan.FromMilliseconds(15000), Timeout.InfiniteTimeSpan); } } /// /// Wait for timer callback to be completed. /// private void OnQueueTimerCallback(object? state) { try { OnQueueTimerCallbackInternal(); } catch (Exception ex) { _logger.LogError(ex, "Error in OnQueueTimerCallbackInternal"); } } /// /// Wait for timer to be completed. /// private void OnQueueTimerCallbackInternal() { _logger.LogInformation("Timer elapsed - start analyzing"); Plugin.Instance!.AnalysisRunning = true; var progress = new Progress(); var cancellationToken = new CancellationToken(false); // intro var introBaseAnalyzer = new BaseItemAnalyzerTask( MediaSegmentType.Intro, _loggerFactory.CreateLogger(), _loggerFactory, _libraryManager); introBaseAnalyzer.AnalyzeItems(progress, cancellationToken); // outro var outroBaseAnalyzer = new BaseItemAnalyzerTask( MediaSegmentType.Outro, _loggerFactory.CreateLogger(), _loggerFactory, _libraryManager); outroBaseAnalyzer.AnalyzeItems(progress, cancellationToken); Plugin.Instance!.AnalysisRunning = false; // we might need to analyze again if (_analyzeAgain) { _logger.LogInformation("Analyzing ended, but we need to analyze again!"); _analyzeAgain = false; StartTimer(); } } /// public void Dispose() { _queueTimer.Dispose(); } } ================================================ FILE: Jellyfin.Plugin.MediaAnalyzer/FFmpegWrapper.cs ================================================ using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Text; using System.Text.RegularExpressions; using Jellyfin.Data.Enums; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.MediaAnalyzer; /// /// Wrapper for libchromaprint and the silencedetect filter. /// public static class FFmpegWrapper { private static readonly object InvertedIndexCacheLock = new(); /// /// Used with FFmpeg's silencedetect filter to extract the start and end times of silence. /// private static readonly Regex SilenceDetectionExpression = new( "silence_(?start|end): (?