├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml ├── renovate.json └── workflows │ ├── build.yaml │ ├── command-dispatch.yaml │ ├── command-rebase.yaml │ ├── publish.yaml │ ├── scan-codeql.yaml │ ├── sync-labels.yaml │ └── test.yaml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── Directory.Build.props ├── Directory.Packages.props ├── Jellyfin.Plugin.LocalPosters.Tests ├── ArtMatcherTests.cs ├── BorderRemover_Specs.cs ├── BorderReplacer_Specs.cs ├── EpisodeMatcherTests.cs ├── Jellyfin.Plugin.LocalPosters.Tests.csproj ├── MovieCollectionMatcherTests.cs ├── MovieMatcherTests.cs ├── NoopImageProcessor.cs ├── SeasonMatcherTests.cs └── SeriesMatcherTests.cs ├── Jellyfin.Plugin.LocalPosters.sln ├── Jellyfin.Plugin.LocalPosters ├── Configuration │ ├── PluginConfiguration.cs │ ├── PluginConfigurationExtensions.cs │ ├── configPage.html │ └── gdrives.json ├── Context.cs ├── Controllers │ ├── GoogleAuthorizationController.cs │ └── UnmatchedAssetsController.cs ├── Entities │ └── PosterRecord.cs ├── GDrive │ ├── GDriveServiceProvider.cs │ ├── GDriveSyncClient.cs │ └── ISyncClient.cs ├── Jellyfin.Plugin.LocalPosters.csproj ├── LocalPostersPlugin.cs ├── Logging │ └── LoggerExtensions.cs ├── Matchers │ ├── ArtMatcher.cs │ ├── CachedImageSearcher.cs │ ├── EpisodeMatcher.cs │ ├── IImageSearcher.cs │ ├── IMatcher.cs │ ├── IMatcherFactory.cs │ ├── ImageSearcher.cs │ ├── MovieCollectionMatcher.cs │ ├── MovieMatcher.cs │ ├── SeasonMatcher.cs │ ├── SeriesMatcher.cs │ └── StringExtensions.cs ├── Migrations │ ├── 20250212145737_Initial.Designer.cs │ ├── 20250212145737_Initial.cs │ ├── 20250212152920_MatchedAt.Designer.cs │ ├── 20250212152920_MatchedAt.cs │ ├── 20250212153309_MakePosterRequired.Designer.cs │ ├── 20250212153309_MakePosterRequired.cs │ ├── 20250226102653_MoreFields.Designer.cs │ ├── 20250226102653_MoreFields.cs │ ├── 20250228102807_AddRowVersion.Designer.cs │ ├── 20250228102807_AddRowVersion.cs │ ├── 20250309185708_ChangePrimaryKey.Designer.cs │ ├── 20250309185708_ChangePrimaryKey.cs │ └── ContextModelSnapshot.cs ├── PluginServiceRegistrator.cs ├── Providers │ ├── LocalImageProvider.cs │ └── ValueCache.cs ├── ScheduledTasks │ ├── CleanupTask.cs │ ├── Constants.cs │ ├── SyncGDriveTask.cs │ └── UpdateTask.cs └── Utils │ ├── IImageProcessor.cs │ ├── ImageSizeProvider.cs │ ├── NoopImageResizer.cs │ ├── ProviderManagerExtensions.cs │ ├── SkiaImageResizer.cs │ ├── SkiaSharpBorderRemover.cs │ └── SkiaSharpImageProcessor.cs ├── LICENSE ├── README.md ├── build.yaml └── jellyfin.ruleset /.editorconfig: -------------------------------------------------------------------------------- 1 | # With more recent updates Visual Studio 2017 supports EditorConfig files out of the box 2 | # Visual Studio Code needs an extension: https://github.com/editorconfig/editorconfig-vscode 3 | # For emacs, vim, np++ and other editors, see here: https://github.com/editorconfig 4 | ############################### 5 | # Core EditorConfig Options # 6 | ############################### 7 | root = true 8 | # All files 9 | [*] 10 | indent_style = space 11 | indent_size = 4 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | end_of_line = lf 16 | max_line_length = 140 17 | 18 | # YAML indentation 19 | [*.{yml,yaml}] 20 | indent_size = 2 21 | 22 | # XML indentation 23 | [*.{csproj,xml}] 24 | indent_size = 2 25 | 26 | ############################### 27 | # .NET Coding Conventions # 28 | ############################### 29 | [*.{cs,vb}] 30 | # Organize usings 31 | dotnet_sort_system_directives_first = true 32 | # this. preferences 33 | dotnet_style_qualification_for_field = false:silent 34 | dotnet_style_qualification_for_property = false:silent 35 | dotnet_style_qualification_for_method = false:silent 36 | dotnet_style_qualification_for_event = false:silent 37 | # Language keywords vs BCL types preferences 38 | dotnet_style_predefined_type_for_locals_parameters_members = true:silent 39 | dotnet_style_predefined_type_for_member_access = true:silent 40 | # Parentheses preferences 41 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent 42 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent 43 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent 44 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent 45 | # Modifier preferences 46 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent 47 | dotnet_style_readonly_field = true:suggestion 48 | # Expression-level preferences 49 | dotnet_style_object_initializer = true:suggestion 50 | dotnet_style_collection_initializer = true:suggestion 51 | dotnet_style_explicit_tuple_names = true:suggestion 52 | dotnet_style_null_propagation = true:suggestion 53 | dotnet_style_coalesce_expression = true:suggestion 54 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent 55 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 56 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 57 | dotnet_style_prefer_auto_properties = true:silent 58 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent 59 | dotnet_style_prefer_conditional_expression_over_return = true:silent 60 | 61 | ############################### 62 | # Naming Conventions # 63 | ############################### 64 | # Style Definitions (From Roslyn) 65 | 66 | # Non-private static fields are PascalCase 67 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion 68 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields 69 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style 70 | 71 | dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field 72 | dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected 73 | dotnet_naming_symbols.non_private_static_fields.required_modifiers = static 74 | 75 | dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case 76 | 77 | # Constants are PascalCase 78 | dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion 79 | dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants 80 | dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style 81 | 82 | dotnet_naming_symbols.constants.applicable_kinds = field, local 83 | dotnet_naming_symbols.constants.required_modifiers = const 84 | 85 | dotnet_naming_style.constant_style.capitalization = pascal_case 86 | 87 | # Static fields are camelCase and start with s_ 88 | dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion 89 | dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields 90 | dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style 91 | 92 | dotnet_naming_symbols.static_fields.applicable_kinds = field 93 | dotnet_naming_symbols.static_fields.required_modifiers = static 94 | 95 | dotnet_naming_style.static_field_style.capitalization = camel_case 96 | dotnet_naming_style.static_field_style.required_prefix = _ 97 | 98 | # Instance fields are camelCase and start with _ 99 | dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion 100 | dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields 101 | dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style 102 | 103 | dotnet_naming_symbols.instance_fields.applicable_kinds = field 104 | 105 | dotnet_naming_style.instance_field_style.capitalization = camel_case 106 | dotnet_naming_style.instance_field_style.required_prefix = _ 107 | 108 | # Locals and parameters are camelCase 109 | dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion 110 | dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters 111 | dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style 112 | 113 | dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local 114 | 115 | dotnet_naming_style.camel_case_style.capitalization = camel_case 116 | 117 | # Local functions are PascalCase 118 | dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion 119 | dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions 120 | dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style 121 | 122 | dotnet_naming_symbols.local_functions.applicable_kinds = local_function 123 | 124 | dotnet_naming_style.local_function_style.capitalization = pascal_case 125 | 126 | # By default, name items with PascalCase 127 | dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion 128 | dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members 129 | dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style 130 | 131 | dotnet_naming_symbols.all_members.applicable_kinds = * 132 | 133 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case 134 | 135 | ############################### 136 | # C# Coding Conventions # 137 | ############################### 138 | [*.cs] 139 | # var preferences 140 | csharp_style_var_for_built_in_types = true:silent 141 | csharp_style_var_when_type_is_apparent = true:silent 142 | csharp_style_var_elsewhere = true:silent 143 | # Expression-bodied members 144 | csharp_style_expression_bodied_methods = false:silent 145 | csharp_style_expression_bodied_constructors = false:silent 146 | csharp_style_expression_bodied_operators = false:silent 147 | csharp_style_expression_bodied_properties = true:silent 148 | csharp_style_expression_bodied_indexers = true:silent 149 | csharp_style_expression_bodied_accessors = true:silent 150 | # Pattern matching preferences 151 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 152 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 153 | # Null-checking preferences 154 | csharp_style_throw_expression = true:suggestion 155 | csharp_style_conditional_delegate_call = true:suggestion 156 | # Modifier preferences 157 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion 158 | # Expression-level preferences 159 | csharp_prefer_braces = true:silent 160 | csharp_style_deconstructed_variable_declaration = true:suggestion 161 | csharp_prefer_simple_default_expression = true:suggestion 162 | csharp_style_pattern_local_over_anonymous_function = true:suggestion 163 | csharp_style_inlined_variable_declaration = true:suggestion 164 | 165 | ############################### 166 | # C# Formatting Rules # 167 | ############################### 168 | # New line preferences 169 | csharp_new_line_before_open_brace = all 170 | csharp_new_line_before_else = true 171 | csharp_new_line_before_catch = true 172 | csharp_new_line_before_finally = true 173 | csharp_new_line_before_members_in_object_initializers = true 174 | csharp_new_line_before_members_in_anonymous_types = true 175 | csharp_new_line_between_query_expression_clauses = true 176 | # Indentation preferences 177 | csharp_indent_case_contents = true 178 | csharp_indent_switch_labels = true 179 | csharp_indent_labels = flush_left 180 | # Space preferences 181 | csharp_space_after_cast = false 182 | csharp_space_after_keywords_in_control_flow_statements = true 183 | csharp_space_between_method_call_parameter_list_parentheses = false 184 | csharp_space_between_method_declaration_parameter_list_parentheses = false 185 | csharp_space_between_parentheses = false 186 | csharp_space_before_colon_in_inheritance_clause = true 187 | csharp_space_after_colon_in_inheritance_clause = true 188 | csharp_space_around_binary_operators = before_and_after 189 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 190 | csharp_space_between_method_call_name_and_opening_parenthesis = false 191 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 192 | # Wrapping preferences 193 | csharp_preserve_single_line_statements = true 194 | csharp_preserve_single_line_blocks = true 195 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically normalize line endings. 2 | * text=auto 3 | 4 | *.jpg binary 5 | *.png binary 6 | *.gif binary 7 | 8 | # Force bash scripts to always use lf line endings so that if a repo is accessed 9 | # in Unix via a file share from Windows, the scripts will work. 10 | *.in text eol=lf 11 | *.sh text eol=lf 12 | 13 | # Likewise, force cmd and batch scripts to always use crlf 14 | *.cmd text eol=crlf 15 | *.bat text eol=crlf 16 | 17 | *.cs text=auto diff=csharp 18 | *.resx text=auto 19 | *.hxx text=auto 20 | *.html text=auto 21 | *.htm text=auto 22 | *.css text=auto 23 | *.scss text=auto 24 | *.sass text=auto 25 | *.less text=auto 26 | *.js text=auto 27 | *.lisp text=auto 28 | *.clj text=auto 29 | *.sql text=auto 30 | *.m text=auto 31 | *.erl text=auto 32 | *.fs text=auto 33 | *.fsx text=auto 34 | *.hs text=auto 35 | 36 | *.csproj text=auto 37 | *.sln text=auto eol=crlf 38 | 39 | # CLR specific 40 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Fetch and update latest `nuget` pkgs 4 | - package-ecosystem: nuget 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | open-pull-requests-limit: 10 9 | labels: 10 | - chore 11 | - dependency 12 | - nuget 13 | commit-message: 14 | prefix: chore 15 | include: scope 16 | 17 | # Fetch and update latest `github-actions` pkgs 18 | - package-ecosystem: github-actions 19 | directory: / 20 | schedule: 21 | interval: monthly 22 | open-pull-requests-limit: 10 23 | labels: 24 | - ci 25 | - dependency 26 | - github_actions 27 | commit-message: 28 | prefix: ci 29 | include: scope 30 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>jellyfin/.github//renovate-presets/default" 5 | ] 6 | } -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: '🏗️ Build Plugin' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - '**/*.md' 9 | pull_request: 10 | branches: 11 | - master 12 | paths-ignore: 13 | - '**/*.md' 14 | workflow_dispatch: 15 | 16 | jobs: 17 | call: 18 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@master 19 | with: 20 | dotnet-version: "8.0.*" 21 | dotnet-target: "net8.0" 22 | -------------------------------------------------------------------------------- /.github/workflows/command-dispatch.yaml: -------------------------------------------------------------------------------- 1 | # Allows for the definition of PR and Issue /commands 2 | name: '📟 Slash Command Dispatcher' 3 | 4 | on: 5 | issue_comment: 6 | types: 7 | - created 8 | 9 | jobs: 10 | call: 11 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-dispatch.yaml@master 12 | secrets: 13 | token: . 14 | -------------------------------------------------------------------------------- /.github/workflows/command-rebase.yaml: -------------------------------------------------------------------------------- 1 | name: '🔀 PR Rebase Command' 2 | 3 | on: 4 | repository_dispatch: 5 | types: 6 | - rebase-command 7 | 8 | jobs: 9 | call: 10 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-rebase.yaml@master 11 | with: 12 | rebase-head: ${{ github.event.client_payload.pull_request.head.label }} 13 | repository-full-name: ${{ github.event.client_payload.github.payload.repository.full_name }} 14 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 15 | secrets: 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: "🚀 Publish Plugin" 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@master 12 | with: 13 | dotnet-version: "8.0.*" 14 | dotnet-target: "net8.0" 15 | upload: 16 | runs-on: ubuntu-latest 17 | needs: 18 | - build 19 | steps: 20 | - name: Download Artifact 21 | uses: actions/download-artifact@v4 22 | with: 23 | name: build-artifact 24 | - name: Prepare GitHub Release assets 25 | run: |- 26 | for file in ./*; do 27 | md5sum ${file#./} >> ${file%.*}.md5 28 | sha256sum ${file#./} >> ${file%.*}.sha256 29 | done 30 | ls -l 31 | - name: Upload GitHub Release assets 32 | uses: shogo82148/actions-upload-release-asset@v1.8.0 33 | with: 34 | upload_url: ${{ github.event.release.upload_url }} 35 | asset_path: ./* 36 | generate: 37 | runs-on: ubuntu-latest 38 | needs: 39 | - upload 40 | steps: 41 | - name: Jellyfin plugin repo 42 | uses: Kevinjil/jellyfin-plugin-repo-action@v0.4.3 43 | with: 44 | githubToken: ${{ secrets.GITHUB_TOKEN }} 45 | repository: ${{ github.repository }} 46 | -------------------------------------------------------------------------------- /.github/workflows/scan-codeql.yaml: -------------------------------------------------------------------------------- 1 | name: '🔬 Run CodeQL' 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths-ignore: 7 | - '**/*.md' 8 | pull_request: 9 | branches: [ master ] 10 | paths-ignore: 11 | - '**/*.md' 12 | schedule: 13 | - cron: '24 2 * * 4' 14 | workflow_dispatch: 15 | 16 | jobs: 17 | call: 18 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/scan-codeql.yaml@master 19 | with: 20 | repository-name: NooNameR/Jellyfin.Plugin.LocalPosters 21 | -------------------------------------------------------------------------------- /.github/workflows/sync-labels.yaml: -------------------------------------------------------------------------------- 1 | name: '🏷️ Sync labels' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 1 * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | call: 10 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/sync-labels.yaml@master 11 | secrets: 12 | token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: '🧪 Test Plugin' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - '**/*.md' 9 | pull_request: 10 | branches: 11 | - master 12 | paths-ignore: 13 | - '**/*.md' 14 | workflow_dispatch: 15 | 16 | jobs: 17 | call: 18 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/test.yaml@master 19 | with: 20 | dotnet-version: "8.0.*" 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.suo 2 | *.user 3 | *.dotCover 4 | 5 | .vs 6 | 7 | bin 8 | obj 9 | _ReSharper* 10 | 11 | *.csproj.user 12 | *.resharper.user 13 | *.resharper 14 | *.ReSharper 15 | *.cache 16 | *~ 17 | *.swp 18 | *.bak 19 | *.orig 20 | 21 | **/BenchmarkDotNet.Artifacts/**/* 22 | 23 | # osx noise 24 | .DS_Store 25 | *.DS_Store 26 | *.DotSettings.user 27 | 28 | docs/.vuepress/dist 29 | .vscode 30 | .idea 31 | appsettings.Development.json 32 | 33 | node_modules 34 | *.iml 35 | *.log* 36 | .nuxt 37 | coverage 38 | dist 39 | sw.* 40 | .env 41 | .output 42 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | // List of extensions which should be recommended for users of this workspace. 5 | "recommendations": [ 6 | "ms-dotnettools.csharp", 7 | "editorconfig.editorconfig" 8 | ], 9 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 10 | "unwantedRecommendations": [] 11 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Paths and plugin names are configured in settings.json 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "type": "coreclr", 7 | "name": "Launch", 8 | "request": "launch", 9 | "preLaunchTask": "build-and-copy", 10 | "program": "${config:jellyfinDir}/bin/Debug/net8.0/jellyfin.dll", 11 | "args": [ 12 | //"--nowebclient" 13 | "--webdir", 14 | "${config:jellyfinWebDir}/dist/" 15 | ], 16 | "cwd": "${config:jellyfinDir}", 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // jellyfinDir : The directory of the cloned jellyfin server project 3 | // This needs to be built once before it can be used 4 | "jellyfinDir": "${workspaceFolder}/../jellyfin/Jellyfin.Server", 5 | // jellyfinWebDir : The directory of the cloned jellyfin-web project 6 | // This needs to be built once before it can be used 7 | "jellyfinWebDir": "${workspaceFolder}/../jellyfin-web", 8 | // jellyfinDataDir : the root data directory for a running jellyfin instance 9 | // This is where jellyfin stores its configs, plugins, metadata etc 10 | // This is platform specific by default, but on Windows defaults to 11 | // ${env:LOCALAPPDATA}/jellyfin 12 | // and on Linux, it defaults to 13 | // ${env:XDG_DATA_HOME}/jellyfin 14 | // However ${env:XDG_DATA_HOME} does not work in Visual Studio Code's development container! 15 | "jellyfinWindowsDataDir": "${env:LOCALAPPDATA}/jellyfin", 16 | "jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin", 17 | // The name of the plugin 18 | "pluginName": "Jellyfin.Plugin.Template", 19 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // Paths and plugin name are configured in settings.json 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | // A chain task - build the plugin, then copy it to your 7 | // jellyfin server's plugin directory 8 | "label": "build-and-copy", 9 | "dependsOrder": "sequence", 10 | "dependsOn": [ 11 | "build", 12 | "make-plugin-dir", 13 | "copy-dll" 14 | ] 15 | }, 16 | { 17 | // Build the plugin 18 | "label": "build", 19 | "command": "dotnet", 20 | "type": "shell", 21 | "args": [ 22 | "publish", 23 | "${workspaceFolder}/${config:pluginName}.sln", 24 | "/property:GenerateFullPaths=true", 25 | "/consoleloggerparameters:NoSummary" 26 | ], 27 | "group": "build", 28 | "presentation": { 29 | "reveal": "silent" 30 | }, 31 | "problemMatcher": "$msCompile" 32 | }, 33 | { 34 | // Ensure the plugin directory exists before trying to use it 35 | "label": "make-plugin-dir", 36 | "type": "shell", 37 | "command": "mkdir", 38 | "windows": { 39 | "args": [ 40 | "-Force", 41 | "-Path", 42 | "${config:jellyfinWindowsDataDir}/plugins/${config:pluginName}/" 43 | ] 44 | }, 45 | "linux": { 46 | "args": [ 47 | "-p", 48 | "${config:jellyfinLinuxDataDir}/plugins/${config:pluginName}/" 49 | ] 50 | } 51 | }, 52 | { 53 | // Copy the plugin dll to the jellyfin plugin install path 54 | // This command copies every .dll from the build directory to the plugin dir 55 | // Usually, you probablly only need ${config:pluginName}.dll 56 | // But some plugins may bundle extra requirements 57 | "label": "copy-dll", 58 | "type": "shell", 59 | "command": "cp", 60 | "windows": { 61 | "args": [ 62 | "./${config:pluginName}/bin/Debug/net8.0/publish/*", 63 | "${config:jellyfinWindowsDataDir}/plugins/${config:pluginName}/" 64 | ] 65 | }, 66 | "linux": { 67 | "args": [ 68 | "-r", 69 | "./${config:pluginName}/bin/Debug/net8.0/publish/*", 70 | "${config:jellyfinLinuxDataDir}/plugins/${config:pluginName}/" 71 | ] 72 | } 73 | }, 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | all 13 | runtime; build; native; contentfiles; analyzers 14 | 15 | 16 | all 17 | runtime; build; native; contentfiles; analyzers 18 | 19 | 20 | 21 | 22 | true 23 | NU1902;NU1903 24 | 25 | 26 | 27 | AllEnabledByDefault 28 | 29 | 30 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters.Tests/ArtMatcherTests.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.LocalPosters.Matchers; 2 | using MediaBrowser.Model.Entities; 3 | using Xunit; 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.Tests; 6 | 7 | public sealed class ArtMatcherTests : IDisposable 8 | { 9 | const string Folder = "art-matchers"; 10 | 11 | private readonly DirectoryInfo _folder; 12 | 13 | public ArtMatcherTests() 14 | { 15 | if (!Directory.Exists(Folder)) 16 | Directory.CreateDirectory(Folder); 17 | 18 | _folder = new DirectoryInfo(Folder); 19 | } 20 | 21 | #pragma warning disable CA1063 22 | #pragma warning disable CA1816 23 | public void Dispose() 24 | #pragma warning restore CA1816 25 | #pragma warning restore CA1063 26 | { 27 | Directory.Delete(_folder.FullName, true); 28 | } 29 | 30 | [Fact] 31 | public void MatchSuccessfully() 32 | { 33 | const int SeriesYear = 2025; 34 | const string SeriesName = "Daredevil: Born Again"; 35 | const string OriginalName = "Daredevil: Born Again"; 36 | var matcher = new ArtMatcher(SeriesName, OriginalName, SeriesYear, ImageType.Backdrop); 37 | 38 | Assert.True(matcher.IsMatch("Daredevil- Born Again (2025) - Backdrop.jpg")); 39 | } 40 | 41 | [Fact] 42 | public void MatchSuccessfullyWithoutYear() 43 | { 44 | const string SeriesName = "Daredevil: Born Again"; 45 | const string OriginalName = "Daredevil: Born Again"; 46 | var matcher = new ArtMatcher(SeriesName, OriginalName, null, ImageType.Backdrop); 47 | 48 | Assert.True(matcher.IsMatch("Daredevil- Born Again - Backdrop.jpg")); 49 | } 50 | 51 | [Fact] 52 | public void MatchSuccessfullyWith0Year() 53 | { 54 | const string SeriesName = "Daredevil: Born Again"; 55 | const string OriginalName = "Daredevil: Born Again"; 56 | var matcher = new ArtMatcher(SeriesName, OriginalName, 0, ImageType.Backdrop); 57 | 58 | Assert.True(matcher.IsMatch("Daredevil- Born Again - Backdrop.jpg")); 59 | } 60 | 61 | [Fact] 62 | public void MatchSuccessfullyWithProviderId() 63 | { 64 | const int SeriesYear = 2025; 65 | const string SeriesName = "Daredevil: Born Again"; 66 | const string OriginalName = "Daredevil: Born Again"; 67 | var matcher = new ArtMatcher(SeriesName, OriginalName, SeriesYear, ImageType.Backdrop); 68 | 69 | Assert.True(matcher.IsMatch("Daredevil- Born Again (2025) {tmdb-random} {imdb-random} - Backdrop.jpg")); 70 | } 71 | 72 | [Fact] 73 | public async Task SearchPatternWorksWell() 74 | { 75 | const int Year = 2013; 76 | const string MovieName = "2 Guns"; 77 | const string OriginalName = "2 Guns"; 78 | 79 | var expectedFileName = "2 Guns (2013) - Backdrop.jpg"; 80 | 81 | var matcher = new ArtMatcher(MovieName, OriginalName, Year, ImageType.Backdrop); 82 | 83 | var filePath = Path.Combine(_folder.FullName, expectedFileName); 84 | await File.Create(filePath).DisposeAsync(); 85 | 86 | foreach (var searchPattern in matcher.SearchPatterns) 87 | { 88 | var files = _folder.EnumerateFiles(searchPattern).ToArray(); 89 | 90 | Assert.Single(files); 91 | 92 | Assert.Equal(filePath, files[0].FullName); 93 | 94 | return; 95 | } 96 | 97 | Assert.Fail(); 98 | } 99 | 100 | [Fact] 101 | public async Task SearchPatternWorksWellWithoutYear() 102 | { 103 | const string MovieName = "2 Guns"; 104 | const string OriginalName = "2 Guns"; 105 | 106 | var expectedFileName = "2 Guns - Backdrop.jpg"; 107 | 108 | var matcher = new ArtMatcher(MovieName, OriginalName, null, ImageType.Backdrop); 109 | 110 | var filePath = Path.Combine(_folder.FullName, expectedFileName); 111 | await File.Create(filePath).DisposeAsync(); 112 | 113 | foreach (var searchPattern in matcher.SearchPatterns) 114 | { 115 | var files = _folder.EnumerateFiles(searchPattern).ToArray(); 116 | 117 | Assert.Single(files); 118 | 119 | Assert.Equal(filePath, files[0].FullName); 120 | 121 | return; 122 | } 123 | 124 | Assert.Fail(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters.Tests/BorderRemover_Specs.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Data.Enums; 2 | using Jellyfin.Plugin.LocalPosters.Utils; 3 | using MediaBrowser.Model.Entities; 4 | using Xunit; 5 | 6 | namespace Jellyfin.Plugin.LocalPosters.Tests; 7 | 8 | public class SkiaSharpBorderRemoverTests 9 | { 10 | private readonly SkiaSharpBorderRemover _borderReplacer; 11 | private readonly ImageType _imageType; 12 | private readonly BaseItemKind _kind; 13 | private readonly FileInfo _source; 14 | 15 | public SkiaSharpBorderRemoverTests() 16 | { 17 | _kind = BaseItemKind.Movie; 18 | _imageType = ImageType.Primary; 19 | _source = new FileInfo("abc.jpg"); 20 | _borderReplacer = new SkiaSharpBorderRemover(NoopImageProcessor.Instance); 21 | } 22 | 23 | [Fact] 24 | public void Test() 25 | { 26 | } 27 | 28 | // [Fact] 29 | private void TestBorderRemover() 30 | { 31 | var target = new FileInfo(_source.FullName.Replace(_source.Extension, "", StringComparison.OrdinalIgnoreCase) + "_border_removed" + 32 | _source.Extension); 33 | 34 | using var stream = File.OpenRead(_source.FullName); 35 | using var image = _borderReplacer.Process(_kind, _imageType, stream); 36 | using var fileStream = new FileStream(target.FullName, FileMode.Create, FileAccess.Write); 37 | image.CopyTo(fileStream); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters.Tests/BorderReplacer_Specs.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Data.Enums; 2 | using Jellyfin.Plugin.LocalPosters.Utils; 3 | using MediaBrowser.Model.Entities; 4 | using SkiaSharp; 5 | using Xunit; 6 | 7 | namespace Jellyfin.Plugin.LocalPosters.Tests; 8 | 9 | public class SkiaSharpImageProcessorTests 10 | { 11 | private readonly SkiaSharpImageProcessor _imageProcessor; 12 | private readonly ImageType _imageType; 13 | private readonly BaseItemKind _kind; 14 | private readonly FileInfo _source; 15 | 16 | public SkiaSharpImageProcessorTests() 17 | { 18 | _kind = BaseItemKind.Movie; 19 | _imageType = ImageType.Primary; 20 | _source = new FileInfo("abc.jpg"); 21 | _imageProcessor = new SkiaSharpImageProcessor(SKColors.SkyBlue, NoopImageProcessor.Instance); 22 | } 23 | 24 | [Fact] 25 | public void Test() 26 | { 27 | } 28 | 29 | // [Fact] 30 | private void TestBorderReplacer() 31 | { 32 | var target = new FileInfo(_source.FullName.Replace(_source.Extension, "", StringComparison.OrdinalIgnoreCase) + "_border_replaced" + 33 | _source.Extension); 34 | 35 | using var stream = File.OpenRead(_source.FullName); 36 | using var image = _imageProcessor.Process(_kind, _imageType, stream); 37 | using var fileStream = new FileStream(target.FullName, FileMode.Create, FileAccess.Write); 38 | image.CopyTo(fileStream); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters.Tests/EpisodeMatcherTests.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.LocalPosters.Matchers; 2 | using Xunit; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Tests; 5 | 6 | public class EpisodeMatcherTests 7 | { 8 | [Fact] 9 | public void MatchSuccessfully() 10 | { 11 | const int SeasonYear = 2017; 12 | const int SeriesYear = 2016; 13 | const string SeriesName = "Dexter"; 14 | const string OriginalName = SeriesName; 15 | const int SeasonIndex = 1; 16 | const int EpisodeIndex = 2; 17 | const int EpisodeYear = 2018; 18 | var matcher = new EpisodeMatcher(SeriesName, OriginalName, SeriesYear, SeasonIndex, SeasonYear, EpisodeIndex, EpisodeYear); 19 | 20 | Assert.True(matcher.IsMatch("Dexter (2016) - S1 E2.jpg")); 21 | } 22 | 23 | [Fact] 24 | public void MatchSuccessfullyWithWhitespaceBeforeExtensions() 25 | { 26 | const int SeasonYear = 2017; 27 | const int SeriesYear = 2016; 28 | const string SeriesName = "Dexter"; 29 | const string OriginalName = SeriesName; 30 | const int SeasonIndex = 1; 31 | const int EpisodeIndex = 2; 32 | const int EpisodeYear = 2018; 33 | var matcher = new EpisodeMatcher(SeriesName, OriginalName, SeriesYear, SeasonIndex, SeasonYear, EpisodeIndex, EpisodeYear); 34 | 35 | Assert.True(matcher.IsMatch("Dexter (2016) - S1 E2 .jpg")); 36 | } 37 | 38 | [Fact] 39 | public void MatchSuccessfullyWithoutYear() 40 | { 41 | const int SeasonYear = 2017; 42 | const int SeriesYear = 2016; 43 | const string SeriesName = "Dexter"; 44 | const string OriginalName = SeriesName; 45 | const int SeasonIndex = 1; 46 | const int EpisodeIndex = 2; 47 | const int EpisodeYear = 2018; 48 | var matcher = new EpisodeMatcher(SeriesName, OriginalName, SeriesYear, SeasonIndex, SeasonYear, EpisodeIndex, EpisodeYear); 49 | 50 | Assert.True(matcher.IsMatch("Dexter - S1 E2.jpg")); 51 | } 52 | 53 | [Fact] 54 | public void MatchSuccessfullyForDoubleDigit() 55 | { 56 | const int SeasonYear = 2017; 57 | const int SeriesYear = 2016; 58 | const string SeriesName = "Dexter"; 59 | const string OriginalName = SeriesName; 60 | const int SeasonIndex = 1; 61 | const int EpisodeIndex = 12; 62 | const int EpisodeYear = 2018; 63 | var matcher = new EpisodeMatcher(SeriesName, OriginalName, SeriesYear, SeasonIndex, SeasonYear, EpisodeIndex, EpisodeYear); 64 | 65 | Assert.True(matcher.IsMatch("Dexter (2016) - S01 E12.jpg")); 66 | } 67 | 68 | [Fact] 69 | public void MatchContainingSeriesYearInNameSuccessfully() 70 | { 71 | const int SeasonYear = 2017; 72 | const int SeriesYear = 2016; 73 | const int SeasonIndex = 1; 74 | const int EpisodeIndex = 12; 75 | const int EpisodeYear = 2018; 76 | string seriesName = $"Dexter ({SeriesYear})"; 77 | 78 | var matcher = new EpisodeMatcher(seriesName, seriesName, SeriesYear, SeasonIndex, SeasonYear, EpisodeIndex, EpisodeYear); 79 | 80 | Assert.True(matcher.IsMatch("Dexter (2016) - S01 E12.jpg")); 81 | } 82 | 83 | [Fact] 84 | public void MatchContainingSeasonYearInNameSuccessfully() 85 | { 86 | const int SeasonYear = 2017; 87 | const int SeriesYear = 2016; 88 | const int SeasonIndex = 1; 89 | const int EpisodeIndex = 12; 90 | const int EpisodeYear = 2018; 91 | string seriesName = $"Dexter ({SeasonYear})"; 92 | 93 | var matcher = new EpisodeMatcher(seriesName, seriesName, SeriesYear, SeasonIndex, SeasonYear, EpisodeIndex, EpisodeYear); 94 | 95 | Assert.True(matcher.IsMatch("Dexter (2016) - S01 E12.jpg")); 96 | } 97 | 98 | [Fact] 99 | public void MatchSuccessfullyOnEpisodeYear() 100 | { 101 | const int SeasonYear = 2017; 102 | const int SeriesYear = 2016; 103 | const int SeasonIndex = 1; 104 | const int EpisodeIndex = 12; 105 | const int EpisodeYear = 2018; 106 | const string SeriesName = "Dexter"; 107 | const string OriginalName = SeriesName; 108 | 109 | var matcher = new EpisodeMatcher(SeriesName, OriginalName, SeriesYear, SeasonIndex, SeasonYear, EpisodeIndex, EpisodeYear); 110 | 111 | Assert.True(matcher.IsMatch("Dexter (2018) - S01 E12.jpg")); 112 | } 113 | 114 | [InlineData("-")] 115 | [InlineData(":")] 116 | [InlineData("")] 117 | [Theory] 118 | public void MatchSuccessfullyOnSymbols(string symbol) 119 | { 120 | const int SeasonYear = 2017; 121 | const int SeriesYear = 2016; 122 | const int SeasonIndex = 1; 123 | const int EpisodeIndex = 12; 124 | const int EpisodeYear = 2018; 125 | const string SeriesName = "Dexter: New Blood"; 126 | const string OriginalName = "Dexter: New Blood"; 127 | 128 | var matcher = new EpisodeMatcher(SeriesName, OriginalName, SeriesYear, SeasonIndex, SeasonYear, EpisodeIndex, EpisodeYear); 129 | 130 | Assert.True(matcher.IsMatch($"Dexter{symbol} new Blood (2016) - S1 E12.jpg")); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters.Tests/Jellyfin.Plugin.LocalPosters.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | latest 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | all 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters.Tests/MovieCollectionMatcherTests.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.LocalPosters.Matchers; 2 | using Xunit; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Tests; 5 | 6 | public class MovieCollectionMatcherTests 7 | { 8 | [Fact] 9 | public void MatchSuccessfully() 10 | { 11 | const string CollectionName = "Aquaman Collection"; 12 | 13 | var matcher = new MovieCollectionMatcher(CollectionName, CollectionName); 14 | 15 | Assert.True(matcher.IsMatch("Aquaman Collection.jpg")); 16 | } 17 | 18 | 19 | [Fact] 20 | public void SearchPatternMatchSuccessfully() 21 | { 22 | const string FileName = "Aquaman Collection.jpg"; 23 | var folder = Path.GetTempPath(); 24 | var filePath = Path.Combine(folder, FileName); 25 | File.Create(filePath).Dispose(); 26 | try 27 | { 28 | const string CollectionName = "Aquaman Collection"; 29 | 30 | var matcher = new MovieCollectionMatcher(CollectionName, CollectionName); 31 | 32 | foreach (var searchPattern in matcher.SearchPatterns) 33 | { 34 | foreach (var file in new DirectoryInfo(folder).EnumerateFiles(searchPattern, 35 | new EnumerationOptions 36 | { 37 | RecurseSubdirectories = true, 38 | IgnoreInaccessible = true, 39 | MatchCasing = MatchCasing.CaseInsensitive, 40 | AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.Temporary 41 | })) 42 | { 43 | Assert.True(matcher.IsMatch(file.Name)); 44 | } 45 | } 46 | } 47 | finally 48 | { 49 | File.Delete(filePath); 50 | } 51 | } 52 | 53 | [Fact] 54 | public void MatchSuccessfullyWithTmdb() 55 | { 56 | const string CollectionName = "Aquaman Collection"; 57 | 58 | var matcher = new MovieCollectionMatcher(CollectionName, CollectionName); 59 | 60 | Assert.True(matcher.IsMatch("Aquaman Collection {tmdb-random}.jpg")); 61 | } 62 | 63 | [Fact] 64 | public void MatchSuccessfullyWithWhitespaceBeforeExtensions() 65 | { 66 | const string CollectionName = "Aquaman Collection"; 67 | 68 | var matcher = new MovieCollectionMatcher(CollectionName, CollectionName); 69 | 70 | Assert.True(matcher.IsMatch("Aquaman Collection .jpg")); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters.Tests/MovieMatcherTests.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.LocalPosters.Matchers; 2 | using Xunit; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Tests; 5 | 6 | public class MovieMatcherTests 7 | { 8 | [Fact] 9 | public void MatchSuccessfully() 10 | { 11 | const int MovieYear = 2016; 12 | const int PremiereYear = 2016; 13 | const string MovieName = "Dune: Part One"; 14 | 15 | var matcher = new MovieMatcher(MovieName, MovieName, MovieYear, PremiereYear); 16 | 17 | Assert.True(matcher.IsMatch("Dune: Part One (2016).jpg")); 18 | } 19 | 20 | [Fact] 21 | public void MatchSuccessfullyWithProviderId() 22 | { 23 | const int MovieYear = 2016; 24 | const int PremiereYear = 2016; 25 | const string MovieName = "Dune: Part One"; 26 | 27 | var matcher = new MovieMatcher(MovieName, MovieName, MovieYear, PremiereYear); 28 | 29 | Assert.True(matcher.IsMatch("Dune: Part One (2016) {tmdb-random} {imdb-random}.jpg")); 30 | } 31 | 32 | [Fact] 33 | public void MatchSuccessfullyWithWhitespaceBeforeExtensions() 34 | { 35 | const int MovieYear = 2016; 36 | const int PremiereYear = 2016; 37 | const string MovieName = "Dune: Part One"; 38 | 39 | var matcher = new MovieMatcher(MovieName, MovieName, MovieYear, PremiereYear); 40 | 41 | Assert.True(matcher.IsMatch("Dune: Part One (2016) .jpg")); 42 | } 43 | 44 | 45 | [Fact] 46 | public void MatchSuccessfullyWithOddCharacters() 47 | { 48 | const int MovieYear = 2019; 49 | const int PremiereYear = 2019; 50 | const string MovieName = "Pokémon Detective Pikachu (2019)"; 51 | 52 | var matcher = new MovieMatcher(MovieName, MovieName, MovieYear, PremiereYear); 53 | 54 | Assert.True(matcher.IsMatch("Pokemon detective pikachu (2019).jpg")); 55 | } 56 | 57 | [Fact] 58 | public void MatchSuccessfullyOnPremiere() 59 | { 60 | const int MovieYear = 2016; 61 | const int PremiereYear = 2017; 62 | const string MovieName = "Dune: Part One"; 63 | 64 | var matcher = new MovieMatcher(MovieName, MovieName, MovieYear, PremiereYear); 65 | 66 | Assert.True(matcher.IsMatch("Dune: Part One (2017).jpg")); 67 | } 68 | 69 | [Fact] 70 | public void MatchContainingYearInNameSuccessfully() 71 | { 72 | const int MovieYear = 2016; 73 | const int PremiereYear = 2016; 74 | string movieName = $"Dune: Part One ({MovieYear})"; 75 | 76 | var matcher = new MovieMatcher(movieName, movieName, MovieYear, PremiereYear); 77 | 78 | Assert.True(matcher.IsMatch("Dune: Part One (2016).jpg")); 79 | } 80 | 81 | [Fact] 82 | public void MatchContainingPremiereYearInNameSuccessfully() 83 | { 84 | const int MovieYear = 2016; 85 | const int PremiereYear = 2018; 86 | string movieName = $"Dune: Part One ({PremiereYear})"; 87 | 88 | var matcher = new MovieMatcher(movieName, movieName, MovieYear, PremiereYear); 89 | 90 | Assert.True(matcher.IsMatch("Dune: Part One (2016).jpg")); 91 | } 92 | 93 | [Fact] 94 | public void MatchSuccessfullyOnSimpleName() 95 | { 96 | const int MovieYear = 2016; 97 | const int PremiereYear = 2016; 98 | const string MovieName = "Dune: Part One"; 99 | 100 | var matcher = new MovieMatcher(MovieName, MovieName, MovieYear, PremiereYear); 101 | 102 | Assert.True(matcher.IsMatch("Dune (2016).jpg")); 103 | } 104 | 105 | 106 | [InlineData("-")] 107 | [InlineData(":")] 108 | [InlineData("")] 109 | [Theory] 110 | public void MatchSuccessfullyOnSymbols(string symbol) 111 | { 112 | const int MovieYear = 2016; 113 | const int PremiereYear = 2016; 114 | const string MovieName = "Dune: Part One"; 115 | 116 | var matcher = new MovieMatcher(MovieName, MovieName, MovieYear, PremiereYear); 117 | 118 | Assert.True(matcher.IsMatch($"Dune{symbol} Part One (2016).jpg")); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters.Tests/NoopImageProcessor.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Data.Enums; 2 | using Jellyfin.Plugin.LocalPosters.Utils; 3 | using MediaBrowser.Model.Entities; 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.Tests; 6 | 7 | public class NoopImageProcessor : 8 | IImageProcessor 9 | { 10 | public static readonly IImageProcessor Instance = new NoopImageProcessor(); 11 | 12 | private NoopImageProcessor() 13 | { 14 | } 15 | 16 | public Stream Process(BaseItemKind kind, ImageType imageType, Stream stream) 17 | { 18 | ArgumentNullException.ThrowIfNull(stream); 19 | var memoryStream = new MemoryStream(); 20 | stream.CopyTo(memoryStream); 21 | memoryStream.Seek(0, SeekOrigin.Begin); 22 | return memoryStream; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters.Tests/SeasonMatcherTests.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.LocalPosters.Matchers; 2 | using Xunit; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Tests; 5 | 6 | public class SeasonMatcherTests 7 | { 8 | [Fact] 9 | public void MatchSuccessfully() 10 | { 11 | const int SeasonYear = 2017; 12 | const int SeriesYear = 2016; 13 | const string SeriesName = "Dexter"; 14 | const string SeasonName = "Season 1"; 15 | const int SeasonIndex = 1; 16 | var matcher = new SeasonMatcher(SeriesName, SeriesName, SeriesYear, SeasonName, SeasonIndex, SeasonYear); 17 | 18 | Assert.True(matcher.IsMatch("Dexter (2016) - Season 1.jpg")); 19 | } 20 | 21 | [Fact] 22 | public void MatchSuccessfullyWithWhitespaceBeforeExtensions() 23 | { 24 | const int SeasonYear = 2017; 25 | const int SeriesYear = 2016; 26 | const string SeriesName = "Dexter"; 27 | const string SeasonName = "Season 1"; 28 | const int SeasonIndex = 1; 29 | var matcher = new SeasonMatcher(SeriesName, SeriesName, SeriesYear, SeasonName, SeasonIndex, SeasonYear); 30 | 31 | Assert.True(matcher.IsMatch("Dexter (2016) - Season 1 .jpg")); 32 | } 33 | 34 | [Fact] 35 | public void DoesNotMatchWhenSeasonIsDifferent() 36 | { 37 | const int SeasonYear = 2017; 38 | const int SeriesYear = 2016; 39 | const string SeriesName = "Dexter"; 40 | const string SeasonName = "Season 2"; 41 | const int SeasonIndex = 2; 42 | var matcher = new SeasonMatcher(SeriesName, SeriesName, SeriesYear, SeasonName, SeasonIndex, SeasonYear); 43 | 44 | Assert.False(matcher.IsMatch("Dexter (2016) - Season 1.jpg")); 45 | } 46 | 47 | [Fact] 48 | public void MatchSuccessfullyOnIndex() 49 | { 50 | const int SeasonYear = 2017; 51 | const int SeriesYear = 2016; 52 | const string SeriesName = "Dexter"; 53 | const string SeasonName = "SomeRandomSeason"; 54 | const int SeasonIndex = 1; 55 | var matcher = new SeasonMatcher(SeriesName, SeriesName, SeriesYear, SeasonName, SeasonIndex, SeasonYear); 56 | 57 | Assert.True(matcher.IsMatch("Dexter (2016) - Season 1.jpg")); 58 | } 59 | 60 | [Fact] 61 | public void MatchSuccessfullyOnSeasonName() 62 | { 63 | const int SeasonYear = 2017; 64 | const int SeriesYear = 2016; 65 | const string SeriesName = "Dexter"; 66 | const string SeasonName = "Season 1"; 67 | const int SeasonIndex = 2; 68 | var matcher = new SeasonMatcher(SeriesName, SeriesName, SeriesYear, SeasonName, SeasonIndex, SeasonYear); 69 | 70 | Assert.True(matcher.IsMatch("Dexter (2016) - Season 1.jpg")); 71 | } 72 | 73 | [Fact] 74 | public void MatchSuccessfullyWithProviderId() 75 | { 76 | const int SeasonYear = 2017; 77 | const int SeriesYear = 2008; 78 | const string SeriesName = "Star Wars The Clone Wars"; 79 | const string SeasonName = "Season 1"; 80 | const int SeasonIndex = 1; 81 | var matcher = new SeasonMatcher(SeriesName, SeriesName, SeriesYear, SeasonName, SeasonIndex, SeasonYear); 82 | 83 | Assert.True(matcher.IsMatch("Star Wars The Clone Wars (2008) {tmdb-4194} {tvdb-83268} {imdb-tt0458290} - Season 1.jpg")); 84 | } 85 | 86 | [Fact] 87 | public void MatchContainingSeriesYearInNameSuccessfully() 88 | { 89 | const int SeasonYear = 2017; 90 | const int SeriesYear = 2016; 91 | const string SeasonName = "Season 1"; 92 | const int SeasonIndex = 1; 93 | string seriesName = $"Dexter ({SeriesYear})"; 94 | 95 | var matcher = new SeasonMatcher(seriesName, seriesName, SeriesYear, SeasonName, SeasonIndex, SeasonYear); 96 | 97 | Assert.True(matcher.IsMatch("Dexter (2016) - Season 1.jpg")); 98 | } 99 | 100 | [Fact] 101 | public void MatchContainingSeasonYearInNameSuccessfully() 102 | { 103 | const int SeasonYear = 2017; 104 | const int SeriesYear = 2016; 105 | string seriesName = $"Dexter ({SeasonYear})"; 106 | const string SeasonName = "Season 1"; 107 | const int SeasonIndex = 1; 108 | var matcher = new SeasonMatcher(seriesName, seriesName, SeriesYear, SeasonName, SeasonIndex, SeasonYear); 109 | 110 | Assert.True(matcher.IsMatch("Dexter (2016) - Season 1.jpg")); 111 | } 112 | 113 | [Fact] 114 | public void MatchSuccessfullyOnSeasonYear() 115 | { 116 | const int SeasonYear = 2016; 117 | const int SeriesYear = 2017; 118 | const int SeasonIndex = 1; 119 | const string SeasonName = "Season 1"; 120 | const string SeriesName = "Dexter"; 121 | 122 | var matcher = new SeasonMatcher(SeriesName, SeriesName, SeriesYear, SeasonName, SeasonIndex, SeasonYear); 123 | 124 | Assert.True(matcher.IsMatch("Dexter (2016) - Season 1.jpg")); 125 | } 126 | 127 | [InlineData("-")] 128 | [InlineData(":")] 129 | [InlineData("")] 130 | [Theory] 131 | public void MatchSuccessfullyOnSymbols(string symbol) 132 | { 133 | const int SeasonYear = 2017; 134 | const int SeriesYear = 2016; 135 | const int SeasonIndex = 1; 136 | const string SeasonName = "Season 1"; 137 | const string SeriesName = "Dexter: New Blood"; 138 | 139 | var matcher = new SeasonMatcher(SeriesName, SeriesName, SeriesYear, SeasonName, SeasonIndex, SeasonYear); 140 | 141 | Assert.True(matcher.IsMatch($"Dexter{symbol} new Blood (2016) - Season 1.jpg")); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters.Tests/SeriesMatcherTests.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.LocalPosters.Matchers; 2 | using Xunit; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Tests; 5 | 6 | public class SeriesMatcherTests 7 | { 8 | [Fact] 9 | public void MatchSuccessfully() 10 | { 11 | const int SeriesYear = 2016; 12 | const string SeriesName = "Dexter: Original Sins"; 13 | 14 | var matcher = new SeriesMatcher(SeriesName, SeriesName, SeriesYear); 15 | 16 | Assert.True(matcher.IsMatch("Dexter Original Sins (2016).jpg")); 17 | } 18 | 19 | [Fact] 20 | public void MatchSuccessfullyWithProviderId() 21 | { 22 | const int SeriesYear = 2016; 23 | const string SeriesName = "Dexter: Original Sins"; 24 | 25 | var matcher = new SeriesMatcher(SeriesName, SeriesName, SeriesYear); 26 | 27 | Assert.True(matcher.IsMatch("Dexter Original Sins (2016) {tmdb-random} {tvdb-random} {imdb-random}.jpg")); 28 | } 29 | 30 | [Fact] 31 | public void MatchSuccessfullyWithWhitespaceBeforeExtensions() 32 | { 33 | const int SeriesYear = 2016; 34 | const string SeriesName = "Dexter: Original Sins"; 35 | 36 | var matcher = new SeriesMatcher(SeriesName, SeriesName, SeriesYear); 37 | 38 | Assert.True(matcher.IsMatch("Dexter Original Sins (2016) .jpg")); 39 | } 40 | 41 | 42 | [Fact] 43 | public void MatchContainingYearInNameSuccessfully() 44 | { 45 | const int SeriesYear = 2016; 46 | string seriesName = $"Dexter: Original Sins ({SeriesYear})"; 47 | 48 | var matcher = new SeriesMatcher(seriesName, seriesName, SeriesYear); 49 | 50 | Assert.True(matcher.IsMatch("Dexter Original Sins (2016).jpg")); 51 | } 52 | 53 | 54 | [InlineData("-")] 55 | [InlineData(":")] 56 | [InlineData("")] 57 | [Theory] 58 | public void MatchSuccessfullyOnSymbols(string symbol) 59 | { 60 | const int SeriesYear = 2016; 61 | const string SeriesName = "Dexter: Original Sins"; 62 | 63 | var matcher = new SeriesMatcher(SeriesName, SeriesName, SeriesYear); 64 | 65 | Assert.True(matcher.IsMatch($"Dexter{symbol} Original Sins (2016).jpg")); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.LocalPosters", "Jellyfin.Plugin.LocalPosters\Jellyfin.Plugin.LocalPosters.csproj", "{16FC0C08-B502-4CF1-8B60-512D15CCE578}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.LocalPosters.Tests", "Jellyfin.Plugin.LocalPosters.Tests\Jellyfin.Plugin.LocalPosters.Tests.csproj", "{E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Debug|x64 = Debug|x64 11 | Debug|x86 = Debug|x86 12 | Release|Any CPU = Release|Any CPU 13 | Release|x64 = Release|x64 14 | Release|x86 = Release|x86 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Debug|x64.ActiveCfg = Debug|Any CPU 20 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Debug|x64.Build.0 = Debug|Any CPU 21 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Debug|x86.ActiveCfg = Debug|Any CPU 22 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Debug|x86.Build.0 = Debug|Any CPU 23 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Release|Any CPU.Build.0 = Release|Any CPU 25 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Release|x64.ActiveCfg = Release|Any CPU 26 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Release|x64.Build.0 = Release|Any CPU 27 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Release|x86.ActiveCfg = Release|Any CPU 28 | {16FC0C08-B502-4CF1-8B60-512D15CCE578}.Release|x86.Build.0 = Release|Any CPU 29 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Debug|x64.ActiveCfg = Debug|Any CPU 32 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Debug|x64.Build.0 = Debug|Any CPU 33 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Debug|x86.ActiveCfg = Debug|Any CPU 34 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Debug|x86.Build.0 = Debug|Any CPU 35 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Release|x64.ActiveCfg = Release|Any CPU 38 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Release|x64.Build.0 = Release|Any CPU 39 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Release|x86.ActiveCfg = Release|Any CPU 40 | {E92EB134-EC33-4F6B-BEB4-5D5BE35EAE7F}.Release|x86.Build.0 = Release|Any CPU 41 | EndGlobalSection 42 | EndGlobal 43 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Configuration/PluginConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Globalization; 3 | using System.Text.Json; 4 | using MediaBrowser.Model.Plugins; 5 | using SkiaSharp; 6 | 7 | namespace Jellyfin.Plugin.LocalPosters.Configuration; 8 | 9 | /// 10 | /// 11 | /// 12 | public class PluginConfiguration : BasePluginConfiguration 13 | { 14 | private static readonly Lazy _cache = new(ReadJson); 15 | /// 16 | /// 17 | /// 18 | #pragma warning disable CA1819 19 | public FolderItem[] Folders { get; set; } = _cache.Value; 20 | #pragma warning restore CA1819 21 | 22 | /// 23 | /// 24 | /// 25 | public bool EnableBorderReplacer { get; set; } = true; 26 | 27 | /// 28 | /// 29 | /// 30 | public bool ResizeImage { get; set; } = true; 31 | 32 | /// 33 | /// 34 | /// 35 | public bool RemoveBorder { get; set; } = true; 36 | 37 | /// 38 | /// Hex color for border 39 | /// 40 | public string BorderColor { get; set; } = string.Empty; 41 | 42 | /// 43 | /// 44 | /// 45 | public int ConcurrentDownloadLimit { get; set; } = Environment.ProcessorCount * 10; 46 | 47 | /// 48 | /// 49 | /// 50 | public string GoogleClientSecretFile { get; set; } = "/gdrive/client_secrets.json"; 51 | 52 | /// 53 | /// 54 | /// 55 | public string GoogleSaCredentialFile { get; set; } = "/gdrive/rclone_sa.json"; 56 | 57 | /// 58 | /// 59 | /// 60 | public SKColor? SkColor => !string.IsNullOrEmpty(BorderColor) && SKColor.TryParse(BorderColor, out var c) ? c : null; 61 | 62 | static FolderItem[] ReadJson() 63 | { 64 | var type = typeof(PluginConfiguration); 65 | using Stream? stream = type.Assembly.GetManifestResourceStream(string.Format(CultureInfo.InvariantCulture, 66 | "{0}.Configuration.gdrives.json", type)); 67 | if (stream == null) return []; 68 | return JsonSerializer.Deserialize(stream) ?? []; 69 | } 70 | } 71 | 72 | /// 73 | /// 74 | /// 75 | public class FolderItem 76 | { 77 | /// 78 | /// 79 | /// 80 | public string? RemoteId { get; set; } 81 | 82 | /// 83 | /// 84 | /// 85 | public required string LocalPath { get; set; } 86 | 87 | /// 88 | /// 89 | /// 90 | [MemberNotNullWhen(true, nameof(RemoteId))] 91 | public bool IsRemote => !string.IsNullOrEmpty(RemoteId); 92 | } 93 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Configuration/PluginConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Model.IO; 2 | 3 | namespace Jellyfin.Plugin.LocalPosters.Configuration; 4 | 5 | public static class PluginConfigurationExtensions 6 | { 7 | static readonly Lazy _empty = new(() => new FileSystemMetadata { Exists = false }); 8 | 9 | public static FileSystemMetadata GoogleSaCredentialFile(this PluginConfiguration configuration, IFileSystem fileSystem) 10 | { 11 | return string.IsNullOrEmpty(configuration.GoogleSaCredentialFile) 12 | ? _empty.Value 13 | : fileSystem.GetFileInfo(configuration.GoogleSaCredentialFile); 14 | } 15 | 16 | public static FileSystemMetadata GoogleClientSecretFile(this PluginConfiguration configuration, IFileSystem fileSystem) 17 | { 18 | return string.IsNullOrEmpty(configuration.GoogleClientSecretFile) 19 | ? _empty.Value 20 | : fileSystem.GetFileInfo(configuration.GoogleClientSecretFile); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Configuration/configPage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Local Posters Plugin Configuration 6 | 7 | 8 | 9 |
11 |
12 |
13 |
14 |
15 |

Local Posters Configuration

16 |
17 |
18 |

Manage the folder paths for your local posters. You can add or remove folders.

19 |
20 |
21 | 22 |
29 |
Enable poster image resizing. 30 |
31 |
32 | 33 |
41 |
Change a border based on the settings below. 42 |
43 |
44 | 45 |
52 |
Remove border from the poster image. 53 |
54 |
55 | 56 |
57 | 60 |
61 |
62 | 63 |
64 | 72 |
73 | 74 |
75 | 76 |
Secrets file for Google OAuth
77 |
78 | 79 |
80 | 82 |
Service Account file (OAuth is preferred method)
83 |
84 | 85 |
86 | 87 | 89 |
Number of concurrent requests to the GDrive. High number can cause throttling
90 |
91 | 92 |
93 |
94 |

Folders

95 | 99 |
100 | 101 | 112 | 113 |
114 |
115 | 116 | 120 |
121 |
122 |
123 | 124 | 320 |
321 | 322 | 323 | ` 324 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Configuration/gdrives.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "RemoteId": "1VeeQ_frBFpp6AZLimaJSSr0Qsrl6Tb7z", 4 | "LocalPath": "/posters/drazzilb", 5 | "Description": "MM2K" 6 | }, 7 | { 8 | "RemoteId": "1wOhY88zc0wdQU-QQmhm4FzHL9QiCQnpu", 9 | "LocalPath": "/posters/zarox", 10 | "Description": "Homemade - Can contain white text versions of black text MM2K posters" 11 | }, 12 | { 13 | "RemoteId": "1YEuS1pulJAfhKm4L8U9z5-EMtGl-d2s7", 14 | "LocalPath": "/posters/solen_1", 15 | "Description": "Homemade - Can contain no-gradient versions of MM2K posters, Different season posters per show" 16 | }, 17 | { 18 | "RemoteId": "1Xg9Huh7THDbmjeanW0KyRbEm6mGn_jm8", 19 | "LocalPath": "/posters/bz", 20 | "Description": "Homemade - Can contain white text versions of black text MM2K posters" 21 | }, 22 | { 23 | "RemoteId": "1aRngLdC9yO93gvSrTI2LQ_I9BSoGD-7o", 24 | "LocalPath": "/posters/iamspartacus_1", 25 | "Description": "Homemade - Can contain white text versions of black text MM2K posters" 26 | }, 27 | { 28 | "RemoteId": "1alseEnUBjH6CjXh77b5L4R-ZDGdtOMFr", 29 | "LocalPath": "/posters/lion_city_gaming", 30 | "Description": "Homemade - Can contain white text versions of black text MM2K posters" 31 | }, 32 | { 33 | "RemoteId": "1ZfvUgN0qz4lJYkC_iMRjhH-fZ0rDN_Yu", 34 | "LocalPath": "/posters/majorgiant_1", 35 | "Description": "Homemade - Can contain white text versions of black text MM2K posters" 36 | }, 37 | { 38 | "RemoteId": "1KnwxzwBUQzQyKF1e24q_wlFqcER9xYHM", 39 | "LocalPath": "/posters/sahara", 40 | "Description": "Homemade - +1 rank with Stupifier" 41 | }, 42 | { 43 | "RemoteId": "1bBbK_3JeXCy3ElqTwkFHaNoNxYgqtLug", 44 | "LocalPath": "/posters/stupifier", 45 | "Description": "Homemade - To be placed as low as possible" 46 | }, 47 | { 48 | "RemoteId": "1G77TLQvgs_R7HdMWkMcwHL6vd_96cMp7", 49 | "LocalPath": "/posters/quafley", 50 | "Description": "Homemade - Main focus is around animes (especially older lesser known ones). This includes movies." 51 | }, 52 | { 53 | "RemoteId": "1wrSru-46iIN1iqCl2Cjhj5ofdazPgbsz", 54 | "LocalPath": "/posters/dsaq", 55 | "Description": "Homemade - Mostly Dutch media, Dutch media also have Dutch filenaming" 56 | }, 57 | { 58 | "RemoteId": "1LIVG1RbTEd7tTJMbzZr7Zak05XznLFia", 59 | "LocalPath": "/posters/overbook874_tarantula212", 60 | "Description": "Homemade - Mostly Bollywood/Indian media" 61 | }, 62 | { 63 | "RemoteId": "1hEY9qEdXVDzIbnQ4z9Vpo0SVXXuZBZR-", 64 | "LocalPath": "/posters/mareau", 65 | "Description": "Homemade - Mostly Anime/WebTV media, Asked to be ranked just before Collection drives" 66 | }, 67 | { 68 | "RemoteId": "1KJlsnMz-z2RAfNxKZp7sYP_U0SD1V6lS", 69 | "LocalPath": "/posters/tokenminal", 70 | "Description": "Homemade - Mostly Anime, Some French media" 71 | }, 72 | { 73 | "RemoteId": "1Kb1kFZzzKKlq5N_ob8AFxJvStvm9PdiL", 74 | "LocalPath": "/posters/kalyanrajnish", 75 | "Description": "Homemade - Mostly Indian media" 76 | }, 77 | { 78 | "RemoteId": "1ZhcV8Ybja4sJRrVze-twOmb8fEZfZ2Ci", 79 | "LocalPath": "/posters/minimyself", 80 | "Description": "Homemade - Homemade variants, missing posters" 81 | }, 82 | { 83 | "RemoteId": "1TYVIGKpSwhipLyVQQn_OJHTobM6KaokB", 84 | "LocalPath": "/posters/theotherguy_1", 85 | "Description": "Homemade - Movie, Series and Collection poster all in mm2k style with black gradient" 86 | }, 87 | { 88 | "RemoteId": "15faKB1cDQAhjTQCvj8MvGUQb0nBORWGC", 89 | "LocalPath": "/posters/theotherguy_2", 90 | "Description": "Homemade - OG titles: German, Movie, Series and Collection poster all in mm2k style with black gradient. ALL posters are in German language." 91 | } 92 | ] 93 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Context.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.LocalPosters.Entities; 2 | using MediaBrowser.Controller.Library; 3 | using MediaBrowser.Model.Entities; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Design; 6 | 7 | namespace Jellyfin.Plugin.LocalPosters; 8 | 9 | /// 10 | /// 11 | /// 12 | public class Context : DbContext 13 | { 14 | /// 15 | /// 16 | /// 17 | public const string DbName = "local-posters.db"; 18 | 19 | /// 20 | /// 21 | /// 22 | /// 23 | public Context(DbContextOptions options) : base(options) 24 | { 25 | } 26 | 27 | /// 28 | protected override void OnModelCreating(ModelBuilder modelBuilder) 29 | { 30 | base.OnModelCreating(modelBuilder); 31 | new PosterRecordConfiguration().Configure(modelBuilder.Entity()); 32 | } 33 | 34 | /// 35 | /// 36 | /// 37 | public void ApplyMigration() 38 | { 39 | // If database doesn't exist or can't connect, create it with migrations 40 | if (!Database.CanConnect()) 41 | { 42 | Database.Migrate(); 43 | return; 44 | } 45 | 46 | // If migrations table exists, apply pending migrations normally 47 | if (Database.GetAppliedMigrations().Any()) Database.Migrate(); 48 | } 49 | 50 | //TODO: Remove this 51 | public void FixData(ILibraryManager manager) 52 | { 53 | var dbSet = Set(); 54 | var records = dbSet.Where(x => x.ImageType == ImageType.Primary).ToArray(); 55 | var hasChanges = false; 56 | foreach (var record in records) 57 | { 58 | var item = manager.GetItemById(record.ItemId); 59 | if (item == null) 60 | continue; 61 | 62 | record.ItemKind = item.GetBaseItemKind(); 63 | dbSet.Update(record); 64 | hasChanges = true; 65 | } 66 | 67 | if (hasChanges) 68 | SaveChanges(); 69 | } 70 | } 71 | 72 | /// 73 | /// 74 | /// 75 | public class ContextFactory : IDesignTimeDbContextFactory 76 | { 77 | /// 78 | public Context CreateDbContext(string[] args) 79 | { 80 | var optionsBuilder = new DbContextOptionsBuilder(); 81 | optionsBuilder.UseSqlite($"Data Source={Context.DbName}") 82 | .EnableSensitiveDataLogging(false); 83 | 84 | return new Context(optionsBuilder.Options); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Controllers/GoogleAuthorizationController.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Mime; 2 | using Google.Apis.Auth.OAuth2; 3 | using Google.Apis.Auth.OAuth2.Flows; 4 | using Google.Apis.Util.Store; 5 | using Jellyfin.Plugin.LocalPosters.Configuration; 6 | using Jellyfin.Plugin.LocalPosters.GDrive; 7 | using MediaBrowser.Model.IO; 8 | using Microsoft.AspNetCore.Authorization; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.Extensions.Logging; 11 | 12 | namespace Jellyfin.Plugin.LocalPosters.Controllers; 13 | 14 | /// 15 | /// 16 | /// 17 | [Authorize] 18 | [ApiController] 19 | [Route("LocalPosters/[controller]/[action]")] 20 | [Produces(MediaTypeNames.Application.Json)] 21 | public class GoogleAuthorizationController( 22 | PluginConfiguration configuration, 23 | GDriveServiceProvider provider, 24 | IFileSystem fileSystem, 25 | IDataStore dataStore, 26 | ILogger logger) : ControllerBase 27 | { 28 | private string? RedirectUrl() => Url.Action("Callback", "GoogleAuthorization", null, Request.Scheme); 29 | 30 | /// 31 | /// 32 | /// 33 | /// 34 | [HttpPost] 35 | public async Task> Authorize() 36 | { 37 | var clientSecretFile = configuration.GoogleClientSecretFile(fileSystem); 38 | if (!clientSecretFile.Exists) 39 | { 40 | logger.LogWarning("Google client secret file: {FilePath} not found", configuration.GoogleClientSecretFile); 41 | return BadRequest("Client secret file does not exist"); 42 | } 43 | 44 | var clientSecrets = await GoogleClientSecrets.FromFileAsync(clientSecretFile.FullName, HttpContext.RequestAborted) 45 | .ConfigureAwait(false); 46 | ArgumentNullException.ThrowIfNull(clientSecrets, nameof(clientSecrets)); 47 | 48 | using var flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer 49 | { 50 | ClientSecrets = clientSecrets.Secrets, Scopes = GDriveServiceProvider.Scopes, DataStore = dataStore, Prompt = "consent" 51 | }); 52 | 53 | var redirectUrl = RedirectUrl(); 54 | var authUrl = flow.CreateAuthorizationCodeRequest(redirectUrl).Build(); 55 | logger.LogInformation("Redirecting to Google API: {AuthUrl}, with callback to: {CallbackUrl}", authUrl, redirectUrl); 56 | 57 | return Ok(authUrl.ToString()); 58 | } 59 | 60 | /// 61 | /// 62 | /// 63 | /// 64 | [HttpGet] 65 | public async Task> Verify() 66 | { 67 | try 68 | { 69 | var driveService = await provider.Provide(HttpContext.RequestAborted).ConfigureAwait(false); 70 | var request = driveService.Files.List(); 71 | request.PageSize = 1; 72 | await request.ExecuteAsync(HttpContext.RequestAborted).ConfigureAwait(false); 73 | return true; 74 | } 75 | catch (Exception e) 76 | { 77 | logger.LogDebug(e, "Google API verification failed"); 78 | return false; 79 | } 80 | } 81 | 82 | /// 83 | /// 84 | /// 85 | /// 86 | /// 87 | [AllowAnonymous] 88 | [HttpGet] 89 | [Produces(MediaTypeNames.Text.Html)] 90 | public async Task Callback([FromQuery] string code) 91 | { 92 | var clientSecretFile = configuration.GoogleClientSecretFile(fileSystem); 93 | var clientSecrets = await GoogleClientSecrets.FromFileAsync(clientSecretFile.FullName, CancellationToken.None) 94 | .ConfigureAwait(false); 95 | 96 | ArgumentNullException.ThrowIfNull(clientSecrets, nameof(clientSecrets)); 97 | 98 | using var flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer 99 | { 100 | ClientSecrets = clientSecrets.Secrets, Scopes = GDriveServiceProvider.Scopes, DataStore = dataStore 101 | }); 102 | 103 | await flow.ExchangeCodeForTokenAsync(GDriveServiceProvider.User, code, RedirectUrl(), CancellationToken.None) 104 | .ConfigureAwait(false); 105 | 106 | const string Html = """ 107 | 108 | 109 | 113 |

You may close this window.

114 | 115 | 116 | """; 117 | return Content(Html, "text/html"); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Controllers/UnmatchedAssetsController.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Mime; 2 | using J2N.Collections.ObjectModel; 3 | using Jellyfin.Data.Enums; 4 | using Jellyfin.Plugin.LocalPosters.Entities; 5 | using Jellyfin.Plugin.LocalPosters.Matchers; 6 | using Jellyfin.Plugin.LocalPosters.Utils; 7 | using MediaBrowser.Controller.Dto; 8 | using MediaBrowser.Controller.Entities; 9 | using MediaBrowser.Controller.Library; 10 | using MediaBrowser.Controller.Providers; 11 | using MediaBrowser.Model.Dto; 12 | using MediaBrowser.Model.Entities; 13 | using MediaBrowser.Model.Querying; 14 | using Microsoft.AspNetCore.Authorization; 15 | using Microsoft.AspNetCore.Mvc; 16 | using Microsoft.EntityFrameworkCore; 17 | 18 | namespace Jellyfin.Plugin.LocalPosters.Controllers; 19 | 20 | [Authorize] 21 | [ApiController] 22 | [Route("LocalPosters/[controller]")] 23 | [Produces(MediaTypeNames.Application.Json)] 24 | public class UnmatchedAssetsController( 25 | IQueryable queryable, 26 | IMatcherFactory matcherFactory, 27 | ILibraryManager libraryManager, 28 | IProviderManager providerManager, 29 | IDirectoryService directoryService, 30 | IDtoService dtoService) : ControllerBase 31 | { 32 | const int BatchSize = 5000; 33 | 34 | /// 35 | /// 36 | /// 37 | /// 38 | [HttpGet("{kind}/{type}")] 39 | public async Task>> Get([FromRoute] BaseItemKind kind, [FromRoute] ImageType type) 40 | { 41 | if (type != ImageType.Primary) 42 | return BadRequest("Image type must be Primary"); 43 | if (!matcherFactory.SupportedItemKinds.Contains(kind)) 44 | return BadRequest("Invalid item kind"); 45 | 46 | var dict = new Dictionary(); 47 | var records = libraryManager.GetCount(new InternalItemsQuery { IncludeItemTypes = [kind], ImageTypes = [type] }); 48 | var ids = new HashSet(await queryable.Where(x => x.ImageType == type).Select(x => x.ItemId) 49 | .ToListAsync(HttpContext.RequestAborted).ConfigureAwait(false)); 50 | var imageRefreshOptions = new ImageRefreshOptions(directoryService) 51 | { 52 | ImageRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceImages = [type] 53 | }; 54 | 55 | for (var startIndex = 0; startIndex < records; startIndex += BatchSize) 56 | { 57 | foreach (var item in libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [kind], ImageTypes = [type] })) 58 | { 59 | HttpContext.RequestAborted.ThrowIfCancellationRequested(); 60 | 61 | if (!ids.Contains(item.Id) && providerManager.HasImageProviderEnabled(item, imageRefreshOptions)) 62 | dict.Add(item.Id, item); 63 | } 64 | } 65 | 66 | var dtoOptions = new DtoOptions { Fields = [ItemFields.Path], ImageTypes = [type], EnableImages = true }; 67 | 68 | return Ok(dtoService.GetBaseItemDtos(new ReadOnlyList(dict.Values.ToList()), dtoOptions)); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Entities/PosterRecord.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Data.Enums; 2 | using MediaBrowser.Controller.Entities; 3 | using MediaBrowser.Model.Entities; 4 | using MediaBrowser.Model.IO; 5 | using Microsoft.EntityFrameworkCore; 6 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | 9 | namespace Jellyfin.Plugin.LocalPosters.Entities; 10 | 11 | /// 12 | /// 13 | /// 14 | public class PosterRecord 15 | { 16 | private string _posterPath; 17 | 18 | private PosterRecord(Guid itemId, BaseItemKind itemKind, ImageType imageType, DateTimeOffset createdAt, string posterPath) 19 | { 20 | ItemId = itemId; 21 | ItemKind = itemKind; 22 | ImageType = imageType; 23 | CreatedAt = createdAt; 24 | _posterPath = posterPath; 25 | } 26 | 27 | public PosterRecord(BaseItem item, ImageType imageType, DateTimeOffset createdAt, FileSystemMetadata poster) : this(item.Id, 28 | item.GetBaseItemKind(), imageType, createdAt, poster.FullName) 29 | { 30 | } 31 | 32 | /// 33 | /// File id 34 | /// 35 | public Guid ItemId { get; private init; } 36 | 37 | /// 38 | /// 39 | /// 40 | public DateTimeOffset CreatedAt { get; private init; } 41 | 42 | /// 43 | /// 44 | /// 45 | public DateTimeOffset MatchedAt { get; private set; } 46 | 47 | // TODO: replace after migration 48 | /// 49 | /// 50 | /// 51 | public ImageType ImageType { get; set; } 52 | 53 | // TODO: replace after migration 54 | /// 55 | /// 56 | /// 57 | public BaseItemKind ItemKind { get; set; } 58 | 59 | /// 60 | /// 61 | /// 62 | /// 63 | /// 64 | public FileSystemMetadata PosterFile(IFileSystem fileSystem) 65 | { 66 | return fileSystem.GetFileInfo(_posterPath); 67 | } 68 | 69 | /// 70 | /// 71 | /// 72 | /// 73 | /// 74 | public void SetPosterFile(FileSystemMetadata path, DateTimeOffset now) 75 | { 76 | if (!path.Exists) 77 | throw new FileNotFoundException("File does not exist", path.FullName); 78 | 79 | MatchedAt = now; 80 | _posterPath = path.FullName; 81 | } 82 | } 83 | 84 | /// 85 | /// 86 | /// 87 | public class PosterRecordConfiguration : IEntityTypeConfiguration 88 | { 89 | /// 90 | public void Configure(EntityTypeBuilder builder) 91 | { 92 | builder.HasKey(x => new { x.ItemId, x.ImageType }); 93 | builder.Property("_posterPath") 94 | .HasColumnName("PosterPath") 95 | .IsRequired(); 96 | 97 | builder.Property(t => t.ItemKind) 98 | .HasConversion(new EnumToStringConverter()) 99 | .IsRequired(); 100 | builder.Property(t => t.ImageType) 101 | .HasConversion(new EnumToStringConverter()) 102 | .HasDefaultValue(ImageType.Primary) 103 | .IsRequired(); 104 | 105 | builder.Property("RowVersion") 106 | .IsRowVersion(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/GDrive/GDriveServiceProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using Google.Apis.Auth.OAuth2; 3 | using Google.Apis.Drive.v3; 4 | using Google.Apis.Services; 5 | using Google.Apis.Util.Store; 6 | using Jellyfin.Plugin.LocalPosters.Configuration; 7 | using MediaBrowser.Model.IO; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace Jellyfin.Plugin.LocalPosters.GDrive; 11 | 12 | /// 13 | /// 14 | /// 15 | public sealed class GDriveServiceProvider( 16 | PluginConfiguration configuration, 17 | LocalPostersPlugin plugin, 18 | IFileSystem fileSystem, 19 | IDataStore dataStore, 20 | ILogger logger) 21 | : IDisposable 22 | { 23 | public const string User = "local-posters-user"; 24 | 25 | private const string ApplicationName = "Jellyfin.Plugin.LocalPosters"; 26 | public static readonly HashSet Scopes = [DriveService.Scope.DriveReadonly]; 27 | private readonly SemaphoreSlim _lock = new(1); 28 | private DriveService? _driveService; 29 | 30 | /// 31 | public void Dispose() 32 | { 33 | _lock.Dispose(); 34 | _driveService?.Dispose(); 35 | } 36 | 37 | /// 38 | /// 39 | /// 40 | /// 41 | /// 42 | /// 43 | [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP003:Dispose previous before re-assigning")] 44 | public async Task Provide(CancellationToken cancellationToken) 45 | { 46 | await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); 47 | 48 | try 49 | { 50 | if (_driveService is not null) 51 | return _driveService; 52 | 53 | if (fileSystem.GetFiles(plugin.GDriveTokenFolder).Any()) 54 | { 55 | var clientSecretFile = configuration.GoogleClientSecretFile(fileSystem); 56 | if (clientSecretFile.Exists) 57 | { 58 | logger.LogDebug("Using token from: {GDriveTokenFolder} and {GoogleClientSecretFile} client secret file", 59 | plugin.GDriveTokenFolder, clientSecretFile.FullName); 60 | 61 | var clientSecrets = await GoogleClientSecrets.FromFileAsync(clientSecretFile.FullName, cancellationToken) 62 | .ConfigureAwait(false); 63 | ArgumentNullException.ThrowIfNull(clientSecrets, nameof(clientSecrets)); 64 | 65 | var credential = await GoogleWebAuthorizationBroker.AuthorizeAsync( 66 | clientSecrets.Secrets, Scopes, User, cancellationToken, dataStore).ConfigureAwait(false); 67 | 68 | if (string.IsNullOrEmpty(credential.Token.RefreshToken)) 69 | logger.LogWarning( 70 | "Refresh token is missing. Please revoke access from Google API Dashboard and request a new token."); 71 | 72 | _driveService = new DriveService(new BaseClientService.Initializer 73 | { 74 | HttpClientInitializer = credential, ApplicationName = ApplicationName 75 | }); 76 | } 77 | } 78 | 79 | var saCredentialFile = configuration.GoogleSaCredentialFile(fileSystem); 80 | if (saCredentialFile.Exists) 81 | { 82 | logger.LogDebug("Using Service Account credentials file: {GoogleSaCredentialFile}", 83 | saCredentialFile.FullName); 84 | 85 | var credential = (await GoogleCredential.FromFileAsync(saCredentialFile.FullName, cancellationToken) 86 | .ConfigureAwait(false)) 87 | .CreateScoped(Scopes); 88 | 89 | _driveService = new DriveService(new BaseClientService.Initializer 90 | { 91 | HttpClientInitializer = credential, ApplicationName = ApplicationName 92 | }); 93 | } 94 | 95 | return _driveService ?? throw new ArgumentException("No Google credentials were found."); 96 | } 97 | finally 98 | { 99 | _lock.Release(); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/GDrive/GDriveSyncClient.cs: -------------------------------------------------------------------------------- 1 | using Google.Apis.Drive.v3; 2 | using MediaBrowser.Model.IO; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.GDrive; 6 | 7 | /// 8 | /// 9 | /// 10 | public sealed class GDriveSyncClient( 11 | ILogger logger, 12 | GDriveServiceProvider driveServiceProvider, 13 | string folderId, 14 | string path, 15 | IFileSystem fileSystem, 16 | SemaphoreSlim limiter) : ISyncClient 17 | { 18 | /// 19 | /// 20 | /// 21 | public const string DownloadLimiterKey = "DownloadLimiterKey"; 22 | 23 | private const string FolderMimeType = "application/vnd.google-apps.folder"; 24 | 25 | /// 26 | public async Task SyncAsync(IProgress progress, CancellationToken cancellationToken) 27 | { 28 | var itemIds = new List<(string, FileSystemMetadata)>(); 29 | var filesToRemove = new List(); 30 | await limiter.WaitAsync(cancellationToken).ConfigureAwait(false); 31 | 32 | var driveService = await driveServiceProvider.Provide(cancellationToken).ConfigureAwait(false); 33 | 34 | try 35 | { 36 | var queue = new Queue<(string, FileSystemMetadata)>(); 37 | queue.Enqueue((folderId, fileSystem.GetDirectoryInfo(path))); 38 | while (queue.TryDequeue(out var q)) 39 | { 40 | var folder = q.Item2; 41 | if (!folder.Exists) 42 | Directory.CreateDirectory(folder.FullName); 43 | 44 | var request = driveService.Files.List(); 45 | request.Q = 46 | $"'{q.Item1}' in parents and trashed=false and (mimeType contains 'image/' or mimeType='{FolderMimeType}')"; 47 | request.Fields = "nextPageToken, files(id, name, size, mimeType, md5Checksum, modifiedTime)"; 48 | request.PageSize = 1000; 49 | 50 | var filesInGDrive = new HashSet(); 51 | 52 | do 53 | { 54 | var result = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false); 55 | logger.LogDebug("Discovered {NumFiles} (PageSize: {PageSize}) files in: {FolderId}", result.Files.Count, 56 | request.PageSize, q.Item1); 57 | 58 | foreach (var file in result.Files) 59 | { 60 | var filePath = fileSystem.GetFileInfo(Path.Combine(folder.FullName, file.Name)); 61 | 62 | if (file.MimeType == FolderMimeType) 63 | queue.Enqueue((file.Id, filePath)); 64 | else if (ShouldDownload(filePath, file)) 65 | itemIds.Add((file.Id, filePath)); 66 | 67 | filesInGDrive.Add(filePath.FullName); 68 | } 69 | 70 | request.PageToken = result.NextPageToken; 71 | } while (!string.IsNullOrEmpty(request.PageToken)); 72 | 73 | filesToRemove.AddRange(fileSystem.GetFiles(folder.FullName).Where(file => !filesInGDrive.Contains(file.FullName))); 74 | } 75 | } 76 | finally 77 | { 78 | limiter.Release(); 79 | } 80 | 81 | progress.Report(10); 82 | 83 | var completed = 0; 84 | 85 | logger.LogInformation("Retrieved {Number} new files from {FolderId}", itemIds.Count, folderId); 86 | 87 | var tasks = itemIds.Select((item, _) => Task.Run(async () => 88 | { 89 | await limiter.WaitAsync(cancellationToken).ConfigureAwait(false); 90 | try 91 | { 92 | logger.LogDebug("Starting file: {FileId} download to: {Folder}", item.Item1, item.Item2.FullName); 93 | 94 | await DownloadFile(driveService, item.Item1, item.Item2).ConfigureAwait(false); 95 | logger.LogDebug("File: {FileId} download completed to: {Folder}", item.Item1, item.Item2.FullName); 96 | return true; 97 | } 98 | catch (OperationCanceledException e) when (e.CancellationToken == cancellationToken) 99 | { 100 | TryRemoveFile(item.Item2); 101 | } 102 | catch (Exception e) 103 | { 104 | logger.LogWarning(e, "File: {FileId} download failed to: {Folder}", item.Item1, item.Item2.FullName); 105 | TryRemoveFile(item.Item2); 106 | } 107 | finally 108 | { 109 | limiter.Release(); 110 | 111 | progress.Report((Interlocked.Increment(ref completed) / (double)itemIds.Count) * 90.0); 112 | } 113 | 114 | return false; 115 | }, cancellationToken)); 116 | 117 | var results = await Task.WhenAll(tasks).ConfigureAwait(false); 118 | 119 | logger.LogInformation("Removing {Count} files which are not longer on GDrive.", filesToRemove.Count); 120 | 121 | foreach (var file in filesToRemove) 122 | TryRemoveFile(file); 123 | 124 | progress.Report(100); 125 | 126 | return results.Count(x => x); 127 | 128 | async Task DownloadFile(DriveService service, string fileId, 129 | FileSystemMetadata saveTo) 130 | { 131 | var request = service.Files.Get(fileId); 132 | await using var stream = new FileStream(saveTo.FullName, FileMode.Create, FileAccess.Write); 133 | await request.DownloadAsync(stream, cancellationToken).ConfigureAwait(false); 134 | } 135 | 136 | static void TryRemoveFile(FileSystemMetadata file) 137 | { 138 | try 139 | { 140 | File.Delete(file.FullName); 141 | } 142 | catch 143 | { 144 | //ignore 145 | } 146 | } 147 | 148 | static bool ShouldDownload(FileSystemMetadata file, Google.Apis.Drive.v3.Data.File driveFile) 149 | { 150 | return !file.Exists || file.Length != driveFile.Size || driveFile.ModifiedTimeDateTimeOffset > file.LastWriteTimeUtc; 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/GDrive/ISyncClient.cs: -------------------------------------------------------------------------------- 1 | namespace Jellyfin.Plugin.LocalPosters.GDrive; 2 | 3 | /// 4 | /// 5 | /// 6 | public interface ISyncClient 7 | { 8 | /// 9 | /// 10 | /// 11 | /// 12 | /// 13 | /// 14 | Task SyncAsync(IProgress progress, CancellationToken cancellationToken = default); 15 | } 16 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Jellyfin.Plugin.LocalPosters.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Jellyfin.Plugin.LocalPosters 5 | true 6 | true 7 | enable 8 | AllEnabledByDefault 9 | ../jellyfin.ruleset 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/LocalPostersPlugin.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using Jellyfin.Plugin.LocalPosters.Configuration; 3 | using MediaBrowser.Common.Configuration; 4 | using MediaBrowser.Common.Plugins; 5 | using MediaBrowser.Controller.Library; 6 | using MediaBrowser.Model.Plugins; 7 | using MediaBrowser.Model.Serialization; 8 | using Microsoft.EntityFrameworkCore; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Jellyfin.Plugin.LocalPosters; 12 | 13 | /// 14 | /// 15 | /// 16 | #pragma warning disable IDISP025 17 | public class LocalPostersPlugin : BasePlugin, IHasWebPages, IDisposable 18 | #pragma warning restore IDISP025 19 | { 20 | /// 21 | /// Gets the provider name. 22 | /// 23 | public const string ProviderName = "Local Posters"; 24 | 25 | private readonly object _lock = new(); 26 | private CancellationTokenSource? _cancellationTokenSource; 27 | 28 | /// 29 | public LocalPostersPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILoggerFactory loggerFactory, 30 | ILibraryManager manager) : base( 31 | applicationPaths, 32 | xmlSerializer) 33 | { 34 | const string PluginDirName = "local-posters"; 35 | var dataFolder = Path.Join(applicationPaths.DataPath, PluginDirName); 36 | 37 | if (!Directory.Exists(dataFolder)) 38 | Directory.CreateDirectory(dataFolder); 39 | 40 | DbPath = Path.Join(dataFolder, Context.DbName); 41 | GDriveTokenFolder = Path.Join(dataFolder, "gdrive"); 42 | 43 | if (Directory.Exists(GDriveTokenFolder)) 44 | Directory.CreateDirectory(GDriveTokenFolder); 45 | 46 | var optionsBuilder = new DbContextOptionsBuilder(); 47 | optionsBuilder.UseSqlite($"Data Source={DbPath}") 48 | .UseLoggerFactory(loggerFactory) 49 | .EnableSensitiveDataLogging(false); 50 | 51 | ConfigurationChanged += (_, _) => ResetToken(); 52 | 53 | var context = new Context(optionsBuilder.Options); 54 | try 55 | { 56 | context.ApplyMigration(); 57 | context.FixData(manager); 58 | } 59 | catch (Exception e) 60 | { 61 | loggerFactory.CreateLogger().LogWarning(e, "Failed to perform migrations."); 62 | } 63 | finally { context.Dispose(); } 64 | 65 | Instance = this; 66 | } 67 | 68 | /// 69 | /// 70 | /// 71 | public CancellationToken ConfigurationToken 72 | { 73 | get 74 | { 75 | lock (_lock) 76 | { 77 | _cancellationTokenSource ??= new CancellationTokenSource(); 78 | return _cancellationTokenSource.Token; 79 | } 80 | } 81 | } 82 | 83 | /// 84 | /// 85 | /// 86 | public string GDriveTokenFolder { get; } 87 | 88 | /// 89 | /// 90 | /// 91 | public static LocalPostersPlugin? Instance { get; private set; } 92 | 93 | /// 94 | public override string Name => "Local Posters"; 95 | 96 | /// 97 | public override Guid Id => new("3938fe98-b7b2-4333-b678-c4c4e339d232"); 98 | 99 | /// 100 | /// 101 | /// 102 | public string DbPath { get; } 103 | 104 | /// 105 | public void Dispose() 106 | { 107 | ResetToken(); 108 | } 109 | 110 | /// 111 | public IEnumerable GetPages() 112 | { 113 | return 114 | [ 115 | new PluginPageInfo 116 | { 117 | Name = Name, 118 | EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", 119 | GetType().Namespace) 120 | } 121 | ]; 122 | } 123 | 124 | void ResetToken() 125 | { 126 | lock (_lock) 127 | { 128 | if (_cancellationTokenSource == null) 129 | return; 130 | 131 | _cancellationTokenSource.Cancel(); 132 | _cancellationTokenSource.Dispose(); 133 | _cancellationTokenSource = null; 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Logging/LoggerExtensions.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Entities; 2 | using MediaBrowser.Controller.Entities.Movies; 3 | using MediaBrowser.Controller.Entities.TV; 4 | using MediaBrowser.Model.Entities; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace Jellyfin.Plugin.LocalPosters.Logging; 8 | 9 | /// 10 | /// 11 | /// 12 | public static class LoggerExtensions 13 | { 14 | private static readonly Action _missingSeasonMessage = 15 | LoggerMessage.Define(LogLevel.Information, 1, 16 | "[{ImageType}]: Was not able to match series: {SeriesName} ({Year}), {Season} (Season {SeasonNumber}), took: {Elapsed}s"); 17 | 18 | private static readonly Action _missingEpisodeMessage = 19 | LoggerMessage.Define(LogLevel.Information, 1, 20 | "[{ImageType}]: Was not able to match episode: {SeriesName} ({Year}), {Season}, Episode {EpisodeNumber}, took: {Elapsed}s"); 21 | 22 | private static readonly Action _missingSeriesMessage = 23 | LoggerMessage.Define(LogLevel.Information, 1, 24 | "[{ImageType}]: Was not able to match series: {SeriesName} ({Year}), took: {Elapsed}s"); 25 | 26 | private static readonly Action _missingMovieMessage = 27 | LoggerMessage.Define(LogLevel.Information, 1, 28 | "[{ImageType}]: Was not able to match movie: {Name} ({Year}), took: {Elapsed}s"); 29 | 30 | private static readonly Action _missingCollectionMessage = 31 | LoggerMessage.Define(LogLevel.Information, 1, 32 | "[{ImageType}]: Was not able to match collection: {Name}, took: {Elapsed}s"); 33 | 34 | private static readonly Action _matchingEpisodeMessage = 35 | LoggerMessage.Define(LogLevel.Debug, 2, 36 | "[{ImageType}]: Matching file {FilePath} for episode: {SeriesName} ({Year}), {Season}, Episode {EpisodeNumber}..."); 37 | 38 | private static readonly Action _matchingSeasonMessage = 39 | LoggerMessage.Define(LogLevel.Debug, 2, 40 | "[{ImageType}]: Matching file {FilePath} for series: {SeriesName} ({Year}), {Season} (Season {SeasonNumber})..."); 41 | 42 | private static readonly Action _matchingSeriesMessage = 43 | LoggerMessage.Define(LogLevel.Debug, 2, 44 | "[{ImageType}]: Matching file {FilePath} for series: {SeriesName} ({Year})..."); 45 | 46 | private static readonly Action _matchingMovieMessage = 47 | LoggerMessage.Define(LogLevel.Debug, 2, 48 | "[{ImageType}]: Matching file {FilePath} for movie: {Name} ({Year})..."); 49 | 50 | private static readonly Action _matchingCollectionMessage = 51 | LoggerMessage.Define(LogLevel.Debug, 2, "[{ImageType}]: Matching file {FilePath} for movie: {Name}..."); 52 | 53 | private static readonly Action _matchedEpisodeMessage = 54 | LoggerMessage.Define(LogLevel.Debug, 3, 55 | "[{ImageType}]: File {FilePath} match series: {SeriesName}, {Season}, Episode {EpisodeNumber}, took: {Elapsed}s"); 56 | 57 | private static readonly Action _matchedSeasonMessage = 58 | LoggerMessage.Define(LogLevel.Debug, 3, 59 | "[{ImageType}]: File {FilePath} match episode: {Name} ({Year}), Season {SeasonNumber}, took: {Elapsed}s"); 60 | 61 | private static readonly Action _matchedSeriesMessage = 62 | LoggerMessage.Define(LogLevel.Debug, 3, 63 | "[{ImageType}]: File {FilePath} match series: {SeriesName} ({Year}), took: {Elapsed}s"); 64 | 65 | private static readonly Action _matchedMovieMessage = 66 | LoggerMessage.Define(LogLevel.Debug, 3, 67 | "[{ImageType}]: File {FilePath} match movie: {Name} ({Year}), took: {Elapsed}s"); 68 | 69 | private static readonly Action _matchedCollectionMessage = 70 | LoggerMessage.Define(LogLevel.Debug, 3, 71 | "[{ImageType}]: File {FilePath} match movie: {Name}, took: {Elapsed}s"); 72 | 73 | 74 | /// 75 | /// 76 | /// 77 | /// 78 | /// 79 | /// 80 | /// 81 | public static void LogMatching(this ILogger logger, FileInfo file, ImageType imageType, BaseItem item) 82 | { 83 | switch (item) 84 | { 85 | case Episode episode: 86 | _matchingEpisodeMessage(logger, imageType, file.FullName, episode.Series?.Name ?? "Unknown Series", 87 | episode.Series?.ProductionYear ?? episode.Season.ProductionYear ?? episode.ProductionYear, 88 | episode.Season?.Name ?? "Unknown Season", 89 | episode.IndexNumber, 90 | null); 91 | break; 92 | case Season season: 93 | _matchingSeasonMessage(logger, imageType, file.FullName, season.Series?.Name ?? "Unknown Series", 94 | season.Series?.ProductionYear ?? season.ProductionYear, 95 | season.Name, 96 | season.IndexNumber, 97 | null); 98 | break; 99 | case Series series: 100 | _matchingSeriesMessage(logger, imageType, file.FullName, series.Name, series.ProductionYear, null); 101 | break; 102 | case Movie movie: 103 | _matchingMovieMessage(logger, imageType, file.FullName, movie.Name, movie.ProductionYear, null); 104 | break; 105 | case BoxSet boxSet: 106 | _matchingCollectionMessage(logger, imageType, file.FullName, boxSet.Name, null); 107 | break; 108 | } 109 | } 110 | 111 | /// 112 | /// 113 | /// 114 | /// 115 | /// 116 | /// 117 | /// 118 | /// 119 | public static void LogMatched(this ILogger logger, ImageType imageType, BaseItem item, FileInfo file, TimeSpan elapsedTime) 120 | { 121 | switch (item) 122 | { 123 | case Episode episode: 124 | _matchedEpisodeMessage(logger, imageType, file.FullName, episode.Series?.Name ?? "Unknown Series", 125 | episode.Season?.Name ?? "Unknown Season", 126 | episode.IndexNumber, 127 | elapsedTime.TotalSeconds, 128 | null); 129 | break; 130 | case Season season: 131 | _matchedSeasonMessage(logger, imageType, file.FullName, season.Series?.Name ?? "Unknown Series", 132 | season.Series?.ProductionYear ?? season.ProductionYear, 133 | season.IndexNumber, 134 | elapsedTime.TotalSeconds, 135 | null); 136 | break; 137 | case Series series: 138 | _matchedSeriesMessage(logger, imageType, file.FullName, series.Name, series.ProductionYear, elapsedTime.TotalSeconds, null); 139 | break; 140 | case Movie movie: 141 | _matchedMovieMessage(logger, imageType, file.FullName, movie.Name, movie.ProductionYear, elapsedTime.TotalSeconds, null); 142 | break; 143 | case BoxSet boxSet: 144 | _matchedCollectionMessage(logger, imageType, file.FullName, boxSet.Name, elapsedTime.TotalSeconds, null); 145 | break; 146 | } 147 | } 148 | 149 | /// 150 | /// 151 | /// 152 | /// 153 | /// 154 | /// 155 | /// 156 | public static void LogMissing(this ILogger logger, ImageType imageType, BaseItem item, TimeSpan elapsedTime) 157 | { 158 | switch (item) 159 | { 160 | case Series series: 161 | _missingSeriesMessage(logger, imageType, series.Name, series.ProductionYear, elapsedTime.TotalSeconds, null); 162 | break; 163 | case Season season: 164 | _missingSeasonMessage(logger, imageType, season.Series?.Name ?? "Unknown Series", 165 | season.Series?.ProductionYear ?? season.ProductionYear, season.Name, season.IndexNumber, elapsedTime.TotalSeconds, 166 | null); 167 | break; 168 | case Episode episode: 169 | _missingEpisodeMessage(logger, imageType, episode.Series?.Name ?? "Unknown Series", 170 | episode.Series?.ProductionYear ?? episode.Season.ProductionYear ?? episode.ProductionYear, 171 | episode.Season?.Name ?? "Unknown Season", 172 | episode.IndexNumber, elapsedTime.TotalSeconds, 173 | null); 174 | break; 175 | case Movie movie: 176 | _missingMovieMessage(logger, imageType, movie.Name, movie.ProductionYear, elapsedTime.TotalSeconds, null); 177 | break; 178 | case BoxSet boxSet: 179 | _missingCollectionMessage(logger, imageType, boxSet.Name, elapsedTime.TotalSeconds, null); 180 | break; 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/ArtMatcher.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using MediaBrowser.Controller.Entities; 3 | using MediaBrowser.Controller.Entities.Movies; 4 | using MediaBrowser.Model.Entities; 5 | 6 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 7 | 8 | /// 9 | public partial class ArtMatcher : IMatcher 10 | { 11 | private readonly ImageType _imageType; 12 | private readonly HashSet _names; 13 | private readonly int? _year; 14 | 15 | /// 16 | /// 17 | /// 18 | /// 19 | /// 20 | /// 21 | /// 22 | public ArtMatcher(string name, string originalName, int? year, ImageType imageType) 23 | { 24 | year = year > 0 ? year : null; 25 | 26 | var titles = new[] { name, originalName }.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); 27 | SearchPatterns = 28 | titles.Select(x => $"{x.SanitizeName("*")}*{year}*{imageType}*.*".Replace("**", "*", StringComparison.Ordinal)).ToHashSet(); 29 | _names = titles.Select(x => x.SanitizeName()).ToHashSet(StringComparer.OrdinalIgnoreCase); 30 | _year = year; 31 | _imageType = imageType; 32 | } 33 | 34 | /// 35 | /// 36 | /// 37 | /// 38 | /// 39 | public ArtMatcher(BaseItem item, ImageType imageType) : this(item.Name, item.OriginalTitle, GetYear(item), imageType) 40 | { 41 | } 42 | 43 | /// 44 | public IReadOnlySet SearchPatterns { get; } 45 | 46 | /// 47 | public bool IsMatch(string fileName) 48 | { 49 | if (_names.Count == 0) 50 | return false; 51 | 52 | var match = ArtRegex().Match(fileName); 53 | if (!match.Success) return false; 54 | 55 | if (!Enum.TryParse(match.Groups[3].Value, out var imageType)) return false; 56 | 57 | var name = match.Groups[1].Value.SanitizeName(); 58 | 59 | return (!int.TryParse(match.Groups[2].Value, out var year) || year == _year) && imageType == _imageType && _names.Contains(name); 60 | } 61 | 62 | private static int? GetYear(BaseItem item) 63 | { 64 | return item is BoxSet ? null : item.ProductionYear; 65 | } 66 | 67 | [GeneratedRegex(@"^(.*?)\s*(?:\((\d{4})\))?(?:\s*\{[^}]+\})*\s*-\s*([A-Za-z]+)\s*(\.[a-z]{3,})$", RegexOptions.IgnoreCase)] 68 | private static partial Regex ArtRegex(); 69 | } 70 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/CachedImageSearcher.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Entities; 2 | using MediaBrowser.Model.Entities; 3 | using MediaBrowser.Model.IO; 4 | using Microsoft.Extensions.Caching.Memory; 5 | using Microsoft.Extensions.Primitives; 6 | 7 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 8 | 9 | /// 10 | public class CachedImageSearcher(IImageSearcher searcher, IMemoryCache cache, IFileSystem fileSystem, LocalPostersPlugin plugin) 11 | : IImageSearcher 12 | { 13 | private static readonly TimeSpan _cacheDuration = TimeSpan.FromHours(1); 14 | 15 | /// 16 | public bool IsSupported(BaseItem item) 17 | { 18 | return searcher.IsSupported(item); 19 | } 20 | 21 | /// 22 | public HashSet SupportedImages(BaseItem item) 23 | { 24 | return searcher.SupportedImages(item); 25 | } 26 | 27 | /// 28 | public FileSystemMetadata Search(ImageType imageType, BaseItem item, CancellationToken cancellationToken) 29 | { 30 | var cacheKey = CacheKey(imageType, item); 31 | FileSystemMetadata file; 32 | if (cache.TryGetValue(cacheKey, out var cachedPath) && cachedPath != null) 33 | { 34 | file = fileSystem.GetFileInfo(cachedPath); 35 | 36 | if (file.Exists) 37 | return file; 38 | 39 | cache.Remove(cacheKey); 40 | } 41 | 42 | file = searcher.Search(imageType, item, cancellationToken); 43 | if (file.Exists) 44 | { 45 | var cacheEntryOptions = new MemoryCacheEntryOptions() 46 | .AddExpirationToken(new CancellationChangeToken(plugin.ConfigurationToken)) 47 | .SetAbsoluteExpiration(_cacheDuration); 48 | cache.Set(cacheKey, file.FullName, cacheEntryOptions); 49 | } 50 | 51 | return file; 52 | } 53 | 54 | static string CacheKey(ImageType imageType, BaseItem item) 55 | { 56 | return $"image-searcher-{imageType}.{item.Id}"; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/EpisodeMatcher.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using MediaBrowser.Controller.Entities.TV; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 5 | 6 | /// 7 | public partial class EpisodeMatcher : IMatcher 8 | { 9 | private readonly int? _episodeIndex; 10 | private readonly int? _episodeProductionYear; 11 | private readonly int? _seasonIndex; 12 | private readonly int? _seasonProductionYear; 13 | private readonly HashSet _seriesNames; 14 | private readonly int? _seriesProductionYear; 15 | 16 | /// 17 | /// 18 | /// 19 | /// 20 | /// 21 | /// 22 | /// 23 | /// 24 | /// 25 | /// 26 | public EpisodeMatcher(string seriesName, string originalName, int? seriesProductionYear, int? seasonIndex, int? seasonProductionYear, 27 | int? episodeIndex, 28 | int? episodeProductionYear) 29 | { 30 | var titles = new[] { seriesName, originalName }.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); 31 | SearchPatterns = new[] { seriesName, originalName }.Select(x => 32 | $"{x.SanitizeName("*")}*S{seasonIndex}*E{episodeIndex}*.*".Replace("**", "*", StringComparison.Ordinal)) 33 | .ToHashSet(StringComparer.OrdinalIgnoreCase); 34 | _seriesNames = titles.Select(x => x.SanitizeName()).ToHashSet(StringComparer.OrdinalIgnoreCase); 35 | _episodeIndex = episodeIndex; 36 | _seriesProductionYear = seriesProductionYear; 37 | _seasonIndex = seasonIndex; 38 | _seasonProductionYear = seasonProductionYear; 39 | _episodeProductionYear = episodeProductionYear; 40 | } 41 | 42 | /// 43 | /// 44 | /// 45 | /// 46 | public EpisodeMatcher(Episode episode) : this(episode.Series?.Name ?? string.Empty, episode.Series?.OriginalTitle ?? string.Empty, 47 | episode.Series?.ProductionYear, 48 | episode.Season.IndexNumber, 49 | episode.Season.ProductionYear, 50 | episode.IndexNumber, 51 | episode.ProductionYear) 52 | { 53 | } 54 | 55 | /// 56 | public IReadOnlySet SearchPatterns { get; } 57 | 58 | /// 59 | public bool IsMatch(string fileName) 60 | { 61 | if (_seriesNames.Count == 0) 62 | return false; 63 | 64 | var match = EpisodeRegex().Match(fileName); 65 | if (!match.Success) return false; 66 | 67 | if (!int.TryParse(match.Groups[3].Value, out var season)) return false; 68 | if (!int.TryParse(match.Groups[4].Value, out var episode)) return false; 69 | var seriesName = match.Groups[1].Value.SanitizeName(); 70 | 71 | return IsYearMatch(match.Groups[2].Value) && 72 | _seriesNames.Contains(seriesName) && 73 | _seasonIndex == season && _episodeIndex == episode; 74 | } 75 | 76 | private bool IsYearMatch(string yearString) 77 | { 78 | if (!int.TryParse(yearString, out var year)) return true; 79 | 80 | return year == _episodeProductionYear || year == _seriesProductionYear || year == _seasonProductionYear; 81 | } 82 | 83 | [GeneratedRegex(@"^(.*?)(?:\s*\((\d{4})\))?\s*-\s*S(\d+)\s*E(\d+)\s*(\.[a-z]{3,})$", RegexOptions.IgnoreCase)] 84 | private static partial Regex EpisodeRegex(); 85 | } 86 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/IImageSearcher.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Entities; 2 | using MediaBrowser.Model.Entities; 3 | using MediaBrowser.Model.IO; 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 6 | 7 | /// 8 | /// 9 | /// 10 | public interface IImageSearcher 11 | { 12 | /// 13 | /// 14 | /// 15 | /// 16 | /// 17 | bool IsSupported(BaseItem item); 18 | 19 | /// 20 | /// 21 | /// 22 | /// 23 | /// 24 | HashSet SupportedImages(BaseItem item); 25 | 26 | /// 27 | /// 28 | /// 29 | /// 30 | /// 31 | /// 32 | /// 33 | FileSystemMetadata Search(ImageType imageType, BaseItem item, CancellationToken cancellationToken); 34 | } 35 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/IMatcher.cs: -------------------------------------------------------------------------------- 1 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 2 | 3 | /// 4 | /// 5 | /// 6 | public interface IMatcher 7 | { 8 | /// 9 | /// 10 | /// 11 | IReadOnlySet SearchPatterns { get; } 12 | 13 | /// 14 | /// 15 | /// 16 | /// 17 | /// 18 | bool IsMatch(string fileName); 19 | } 20 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/IMatcherFactory.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Data.Enums; 2 | using MediaBrowser.Controller.Entities; 3 | using MediaBrowser.Controller.Entities.Movies; 4 | using MediaBrowser.Controller.Entities.TV; 5 | using MediaBrowser.Model.Entities; 6 | 7 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 8 | 9 | /// 10 | /// 11 | /// 12 | public interface IMatcherFactory 13 | { 14 | /// 15 | /// 16 | /// 17 | HashSet SupportedItemKinds { get; } 18 | 19 | /// 20 | /// 21 | /// 22 | HashSet SupportedImageTypes(BaseItem item); 23 | 24 | /// 25 | /// 26 | /// 27 | /// 28 | IMatcher Create(ImageType imageType, BaseItem item); 29 | } 30 | 31 | /// 32 | public class MatcherFactory : IMatcherFactory 33 | { 34 | static readonly Dictionary> _factories = new() 35 | { 36 | { BaseItemKind.Movie, item => new MovieMatcher((Movie)item) }, 37 | { BaseItemKind.Season, item => new SeasonMatcher((Season)item) }, 38 | { BaseItemKind.Series, item => new SeriesMatcher((Series)item) }, 39 | { BaseItemKind.Episode, item => new EpisodeMatcher((Episode)item) }, 40 | { BaseItemKind.BoxSet, item => new MovieCollectionMatcher((BoxSet)item) } 41 | }; 42 | 43 | private static readonly HashSet _kinds = [.._factories.Keys]; 44 | 45 | private static readonly HashSet _seriesImageTypes = 46 | [ImageType.Primary, ImageType.Art, ImageType.Backdrop, ImageType.Thumb, ImageType.Banner]; 47 | 48 | 49 | private static readonly HashSet _moviesImageTypes = 50 | [ 51 | ImageType.Primary, 52 | ImageType.Thumb, 53 | ImageType.Art, 54 | ImageType.Logo, 55 | ImageType.Disc, 56 | ImageType.Banner, 57 | ImageType.Backdrop 58 | ]; 59 | 60 | /// 61 | public HashSet SupportedItemKinds => _kinds; 62 | 63 | /// 64 | public HashSet SupportedImageTypes(BaseItem item) 65 | { 66 | return item switch 67 | { 68 | Series => _seriesImageTypes, 69 | Movie or BoxSet => _moviesImageTypes, 70 | Season or Episode => [ImageType.Primary], 71 | _ => throw new NotSupportedException("Not supported item kind") 72 | }; 73 | } 74 | 75 | /// 76 | public IMatcher Create(ImageType imageType, BaseItem item) 77 | { 78 | ArgumentNullException.ThrowIfNull(item); 79 | 80 | if (imageType != ImageType.Primary) 81 | return new ArtMatcher(item, imageType); 82 | 83 | if (!_factories.TryGetValue(item.GetBaseItemKind(), out var factory)) 84 | throw new InvalidOperationException($"No factory registered for type {item.GetType()}"); 85 | 86 | return factory(item); 87 | } 88 | } 89 | 90 | /// 91 | /// 92 | /// 93 | public static class MatcherFactoryExtensions 94 | { 95 | /// 96 | /// 97 | /// 98 | /// 99 | /// 100 | /// 101 | public static bool IsSupported(this IMatcherFactory factory, BaseItem item) => 102 | factory.SupportedItemKinds.Contains(item.GetBaseItemKind()); 103 | } 104 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/ImageSearcher.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Jellyfin.Plugin.LocalPosters.Configuration; 3 | using Jellyfin.Plugin.LocalPosters.Logging; 4 | using MediaBrowser.Controller.Entities; 5 | using MediaBrowser.Model.Entities; 6 | using MediaBrowser.Model.IO; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 10 | 11 | /// 12 | public class ImageSearcher : IImageSearcher 13 | { 14 | private static readonly Lazy _emptyMetadata = new(() => new FileSystemMetadata { Exists = false }); 15 | 16 | private static readonly Lazy _enumerationOptions = 17 | new(() => new EnumerationOptions 18 | { 19 | RecurseSubdirectories = true, 20 | IgnoreInaccessible = true, 21 | MatchCasing = MatchCasing.CaseInsensitive, 22 | AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.Temporary 23 | }); 24 | 25 | private readonly PluginConfiguration _configuration; 26 | private readonly IFileSystem _fileSystem; 27 | private readonly ILogger _logger; 28 | private readonly IMatcherFactory _matcherFactory; 29 | 30 | /// 31 | /// 32 | /// 33 | /// 34 | /// 35 | /// 36 | /// 37 | public ImageSearcher(ILogger logger, IMatcherFactory matcherFactory, IFileSystem fileSystem, 38 | PluginConfiguration configuration) 39 | { 40 | _logger = logger; 41 | _matcherFactory = matcherFactory; 42 | _fileSystem = fileSystem; 43 | _configuration = configuration; 44 | } 45 | 46 | /// 47 | public bool IsSupported(BaseItem item) 48 | { 49 | return _matcherFactory.IsSupported(item); 50 | } 51 | 52 | /// 53 | public HashSet SupportedImages(BaseItem item) 54 | { 55 | return _matcherFactory.SupportedImageTypes(item); 56 | } 57 | 58 | /// 59 | public FileSystemMetadata Search(ImageType imageType, BaseItem item, CancellationToken cancellationToken) 60 | { 61 | var sw = Stopwatch.StartNew(); 62 | var matcher = _matcherFactory.Create(imageType, item); 63 | 64 | for (var i = 0; i < _configuration.Folders.Length; i++) 65 | { 66 | if (string.IsNullOrEmpty(_configuration.Folders[i].LocalPath)) 67 | continue; 68 | 69 | var folder = _fileSystem.GetDirectoryInfo(_configuration.Folders[i].LocalPath); 70 | 71 | if (!folder.Exists || !folder.IsDirectory) 72 | continue; 73 | 74 | // Search for provider id patterns first 75 | foreach (var searchPattern in GetProviderIdSearchPatterns(item)) 76 | if (FileSystemMetadata(folder, searchPattern, out var fileInfo)) 77 | return fileInfo; 78 | 79 | foreach (var searchPattern in matcher.SearchPatterns) 80 | if (FileSystemMetadata(folder, searchPattern, out var fileInfo)) 81 | return fileInfo; 82 | } 83 | 84 | _logger.LogMissing(imageType, item, sw.Elapsed); 85 | 86 | return _emptyMetadata.Value; 87 | 88 | bool FileSystemMetadata(FileSystemMetadata folder, string searchPattern, out FileSystemMetadata fileInfo) 89 | { 90 | // TODO: this is a workaround while waiting for: https://github.com/jellyfin/jellyfin/pull/13691 91 | foreach (var file in new DirectoryInfo(folder.FullName).EnumerateFiles(searchPattern, _enumerationOptions.Value)) 92 | { 93 | cancellationToken.ThrowIfCancellationRequested(); 94 | 95 | _logger.LogMatching(file, imageType, item); 96 | 97 | var match = matcher.IsMatch(file.Name); 98 | 99 | if (!match) 100 | continue; 101 | 102 | _logger.LogMatched(imageType, item, file, sw.Elapsed); 103 | 104 | fileInfo = _fileSystem.GetFileInfo(file.FullName); 105 | return true; 106 | } 107 | 108 | fileInfo = _emptyMetadata.Value; 109 | return false; 110 | } 111 | } 112 | 113 | private IEnumerable GetProviderIdSearchPatterns(BaseItem item) 114 | { 115 | if (item.TryGetProviderId(MetadataProvider.Tmdb, out var id) && !string.IsNullOrWhiteSpace(id)) 116 | yield return $"*tmdb-{id.SanitizeSpecialChars()}*.*"; 117 | 118 | if (item.TryGetProviderId(MetadataProvider.Imdb, out id) && !string.IsNullOrWhiteSpace(id)) 119 | yield return $"*imdb-{id.SanitizeSpecialChars()}*.*"; 120 | 121 | if (item.TryGetProviderId(MetadataProvider.Tvdb, out id) && !string.IsNullOrWhiteSpace(id)) 122 | yield return $"*tvdb-{id.SanitizeSpecialChars()}*.*"; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/MovieCollectionMatcher.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using MediaBrowser.Controller.Entities.Movies; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 5 | 6 | /// 7 | public partial class MovieCollectionMatcher : IMatcher 8 | { 9 | private readonly HashSet _names; 10 | 11 | /// 12 | /// 13 | /// 14 | /// 15 | /// 16 | public MovieCollectionMatcher(string name, string originalName) 17 | { 18 | var titles = new[] { name, originalName }.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); 19 | SearchPatterns = titles.Select(x => $"{x.SanitizeName("*")}*.*".Replace("**", "*", StringComparison.Ordinal)) 20 | .ToHashSet(StringComparer.OrdinalIgnoreCase); 21 | _names = titles.Select(x => x.SanitizeName()).ToHashSet(StringComparer.OrdinalIgnoreCase); 22 | } 23 | 24 | /// 25 | /// 26 | /// 27 | /// 28 | public MovieCollectionMatcher(BoxSet collection) : this(collection.Name, collection.OriginalTitle) 29 | { 30 | } 31 | 32 | /// 33 | public IReadOnlySet SearchPatterns { get; } 34 | 35 | /// 36 | public bool IsMatch(string fileName) 37 | { 38 | if (_names.Count == 0) 39 | return false; 40 | 41 | var match = CollectionRegex().Match(fileName); 42 | return match.Success && _names.Contains(match.Groups[1].Value.SanitizeName()); 43 | } 44 | 45 | [GeneratedRegex(@"^(.*? Collection)(?:\s*\{[^}]+\})*\s*(\.[a-z]{3,})$", RegexOptions.IgnoreCase)] 46 | private static partial Regex CollectionRegex(); 47 | } 48 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/MovieMatcher.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using MediaBrowser.Controller.Entities.Movies; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 5 | 6 | /// 7 | public partial class MovieMatcher : IMatcher 8 | { 9 | private readonly HashSet _names; 10 | private readonly int? _premiereYear; 11 | private readonly int? _productionYear; 12 | 13 | /// 14 | /// 15 | /// 16 | /// 17 | /// 18 | /// 19 | /// 20 | public MovieMatcher(string name, string originalName, int? productionYear, int? premiereYear) 21 | { 22 | var titles = new[] { name, originalName }.Where(x => !string.IsNullOrWhiteSpace(x)).ToHashSet(StringComparer.OrdinalIgnoreCase); 23 | foreach (var title in titles.ToList()) 24 | titles.Add(title.Split(":", StringSplitOptions.RemoveEmptyEntries)[0]); 25 | SearchPatterns = titles.Select(x => $"{x.SanitizeName("*")}*.*".Replace("**", "*", StringComparison.Ordinal)) 26 | .ToHashSet(StringComparer.OrdinalIgnoreCase); 27 | _names = titles.Select(x => x.SanitizeName()).ToHashSet(StringComparer.OrdinalIgnoreCase); 28 | _productionYear = productionYear; 29 | _premiereYear = premiereYear; 30 | } 31 | 32 | /// 33 | /// 34 | /// 35 | /// 36 | public MovieMatcher(Movie movie) : this(movie.Name, movie.OriginalTitle, movie.ProductionYear, movie.PremiereDate?.Year) 37 | { 38 | } 39 | 40 | /// 41 | public IReadOnlySet SearchPatterns { get; } 42 | 43 | /// 44 | public bool IsMatch(string fileName) 45 | { 46 | var match = MovieRegex().Match(fileName); 47 | if (!match.Success) return false; 48 | 49 | if (!int.TryParse(match.Groups[2].Value, out var year)) return false; 50 | var name = match.Groups[1].Value.SanitizeName(); 51 | return (year == _productionYear || year == _premiereYear) && _names.Contains(name); 52 | } 53 | 54 | [GeneratedRegex(@"^(.*?)\s*\((\d{4})\)(?:\s*\{[^}]+\})*\s*(\.[a-z]{3,})$", RegexOptions.IgnoreCase)] 55 | private static partial Regex MovieRegex(); 56 | } 57 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/SeasonMatcher.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using MediaBrowser.Controller.Entities.TV; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 5 | 6 | /// 7 | public partial class SeasonMatcher : IMatcher 8 | { 9 | private readonly HashSet _names; 10 | private readonly int? _seasonIndex; 11 | private readonly string _seasonName; 12 | private readonly int? _seasonProductionYear; 13 | private readonly int? _seriesProductionYear; 14 | 15 | /// 16 | /// 17 | /// 18 | /// 19 | /// 20 | /// 21 | /// 22 | /// 23 | /// 24 | public SeasonMatcher(string seriesName, string originalName, int? seriesProductionYear, string seasonName, int? seasonIndex, 25 | int? seasonProductionYear) 26 | { 27 | var titles = new[] { seriesName, originalName }.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); 28 | SearchPatterns = titles.Select(x => $"{x.SanitizeName("*")}*.*".Replace("**", "*", StringComparison.Ordinal)) 29 | .ToHashSet(StringComparer.OrdinalIgnoreCase); 30 | _names = titles.Select(x => x.SanitizeName()).ToHashSet(StringComparer.OrdinalIgnoreCase); 31 | _seasonName = seasonName.SanitizeName(); 32 | _seasonIndex = seasonIndex; 33 | _seriesProductionYear = seriesProductionYear; 34 | _seasonProductionYear = seasonProductionYear; 35 | } 36 | 37 | /// 38 | /// 39 | /// 40 | /// 41 | public SeasonMatcher(Season season) : this(season.Series.Name ?? string.Empty, season.Series.OriginalTitle ?? string.Empty, 42 | season.Series.ProductionYear, season.Name, 43 | season.IndexNumber, 44 | season.ProductionYear) 45 | { 46 | } 47 | 48 | /// 49 | /// 50 | /// 51 | public IReadOnlySet SearchPatterns { get; } 52 | 53 | /// 54 | public bool IsMatch(string fileName) 55 | { 56 | if (_names.Count == 0) 57 | return false; 58 | 59 | var match = SeasonRegex().Match(fileName); 60 | if (!match.Success) return false; 61 | 62 | if (!int.TryParse(match.Groups[2].Value, out var year)) return false; 63 | 64 | return (year == _seasonProductionYear || year == _seriesProductionYear) && 65 | (string.Equals(match.Groups[3].Value.SanitizeName(), _seasonName, StringComparison.OrdinalIgnoreCase) || 66 | (int.TryParse(match.Groups[4].Value, out var seasonIndex) && seasonIndex == _seasonIndex)) && 67 | _names.Contains(match.Groups[1].Value.SanitizeName()); 68 | } 69 | 70 | [GeneratedRegex(@"^(.*?)\s*\((\d{4})\)(?:\s*\{[^}]+\})*\s*-\s*(?:([a-z]+|Season (\d+)))\s*(\.[a-z]{3,})$", RegexOptions.IgnoreCase)] 71 | private static partial Regex SeasonRegex(); 72 | } 73 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/SeriesMatcher.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using MediaBrowser.Controller.Entities.TV; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 5 | 6 | /// 7 | public partial class SeriesMatcher : IMatcher 8 | { 9 | private readonly HashSet _names; 10 | private readonly int? _productionYear; 11 | 12 | /// 13 | /// 14 | /// 15 | /// 16 | /// 17 | /// 18 | public SeriesMatcher(string name, string originalName, int? productionYear) 19 | { 20 | var titles = new[] { name, originalName }.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); 21 | SearchPatterns = titles.Select(x => $"{x.SanitizeName("*")}*{productionYear}*.*".Replace("**", "*", StringComparison.Ordinal)) 22 | .ToHashSet(StringComparer.OrdinalIgnoreCase); 23 | _names = titles.Select(x => x.SanitizeName()).ToHashSet(StringComparer.OrdinalIgnoreCase); 24 | _productionYear = productionYear; 25 | } 26 | 27 | /// 28 | /// 29 | /// 30 | /// 31 | public SeriesMatcher(Series series) : this(series.Name, series.OriginalTitle, series.ProductionYear) 32 | { 33 | } 34 | 35 | /// 36 | public IReadOnlySet SearchPatterns { get; } 37 | 38 | /// 39 | public bool IsMatch(string fileName) 40 | { 41 | if (_names.Count == 0) 42 | return false; 43 | 44 | var match = SeasonRegex().Match(fileName); 45 | if (!match.Success) return false; 46 | 47 | if (!int.TryParse(match.Groups[2].Value, out var year)) return false; 48 | return (year == _productionYear) && 49 | _names.Contains(match.Groups[1].Value.SanitizeName()); 50 | } 51 | 52 | [GeneratedRegex(@"^(.*?)\s*\((\d{4})\)(?:\s*\{[^}]+\})*\s*(\.[a-z]{3,})$", RegexOptions.IgnoreCase)] 53 | private static partial Regex SeasonRegex(); 54 | } 55 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Matchers/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Text; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.Matchers; 6 | 7 | /// 8 | /// 9 | /// 10 | public static partial class StringExtensions 11 | { 12 | [GeneratedRegex(@"\(\d{4}\)")] 13 | private static partial Regex YearRegex(); 14 | 15 | [GeneratedRegex("[^A-z0-9]")] 16 | private static partial Regex SpecialCharsRegex(); 17 | 18 | [GeneratedRegex(@"\s+")] 19 | private static partial Regex SpacesRegex(); 20 | 21 | /// 22 | /// 23 | /// 24 | /// 25 | /// 26 | /// 27 | public static string SanitizeName(this string input, string replacement = " ") 28 | { 29 | input = RemoveDiacritics(input); 30 | 31 | input = YearRegex().Replace(input, string.Empty); 32 | input = SanitizeSpecialChars(input, replacement); 33 | return SpacesRegex().Replace(input, replacement).Trim(); 34 | } 35 | 36 | public static string SanitizeSpecialChars(this string input, string replacement = "") 37 | { 38 | return SpecialCharsRegex().Replace(input, replacement); 39 | } 40 | 41 | private static string RemoveDiacritics(string text) 42 | { 43 | if (string.IsNullOrEmpty(text)) return text; 44 | 45 | var normalizedString = text.Normalize(NormalizationForm.FormD); 46 | var stringBuilder = new StringBuilder(text.Length); 47 | 48 | for (int i = 0; i < normalizedString.Length; i++) 49 | { 50 | char c = normalizedString[i]; 51 | var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); 52 | if (unicodeCategory != UnicodeCategory.NonSpacingMark) 53 | stringBuilder.Append(c); 54 | } 55 | 56 | return stringBuilder.ToString().Normalize(NormalizationForm.FormC); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250212145737_Initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Jellyfin.Plugin.LocalPosters; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | 9 | #nullable disable 10 | 11 | namespace Jellyfin.Plugin.LocalPosters.Migrations 12 | { 13 | [DbContext(typeof(Context))] 14 | [Migration("20250212145737_Initial")] 15 | partial class Initial 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); 22 | 23 | modelBuilder.Entity("Jellyfin.Plugin.LocalPosters.Entities.PosterRecord", b => 24 | { 25 | b.Property("Id") 26 | .ValueGeneratedOnAdd() 27 | .HasColumnType("TEXT"); 28 | 29 | b.Property("CreatedAt") 30 | .HasColumnType("TEXT"); 31 | 32 | b.Property("_posterPath") 33 | .HasColumnType("TEXT") 34 | .HasColumnName("PosterPath"); 35 | 36 | b.HasKey("Id"); 37 | 38 | b.ToTable("PosterRecord"); 39 | }); 40 | #pragma warning restore 612, 618 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250212145737_Initial.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace Jellyfin.Plugin.LocalPosters.Migrations 7 | { 8 | /// 9 | public partial class Initial : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.CreateTable( 15 | name: "PosterRecord", 16 | columns: table => new 17 | { 18 | Id = table.Column(type: "TEXT", nullable: false), 19 | CreatedAt = table.Column(type: "TEXT", nullable: false), 20 | PosterPath = table.Column(type: "TEXT", nullable: true) 21 | }, 22 | constraints: table => 23 | { 24 | table.PrimaryKey("PK_PosterRecord", x => x.Id); 25 | }); 26 | } 27 | 28 | /// 29 | protected override void Down(MigrationBuilder migrationBuilder) 30 | { 31 | migrationBuilder.DropTable( 32 | name: "PosterRecord"); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250212152920_MatchedAt.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Jellyfin.Plugin.LocalPosters; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | 9 | #nullable disable 10 | 11 | namespace Jellyfin.Plugin.LocalPosters.Migrations 12 | { 13 | [DbContext(typeof(Context))] 14 | [Migration("20250212152920_MatchedAt")] 15 | partial class MatchedAt 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); 22 | 23 | modelBuilder.Entity("Jellyfin.Plugin.LocalPosters.Entities.PosterRecord", b => 24 | { 25 | b.Property("Id") 26 | .ValueGeneratedOnAdd() 27 | .HasColumnType("TEXT"); 28 | 29 | b.Property("CreatedAt") 30 | .HasColumnType("TEXT"); 31 | 32 | b.Property("MatchedAt") 33 | .HasColumnType("TEXT"); 34 | 35 | b.Property("_posterPath") 36 | .HasColumnType("TEXT") 37 | .HasColumnName("PosterPath"); 38 | 39 | b.HasKey("Id"); 40 | 41 | b.ToTable("PosterRecord"); 42 | }); 43 | #pragma warning restore 612, 618 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250212152920_MatchedAt.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace Jellyfin.Plugin.LocalPosters.Migrations 7 | { 8 | /// 9 | public partial class MatchedAt : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.AddColumn( 15 | name: "MatchedAt", 16 | table: "PosterRecord", 17 | type: "TEXT", 18 | nullable: false, 19 | defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); 20 | } 21 | 22 | /// 23 | protected override void Down(MigrationBuilder migrationBuilder) 24 | { 25 | migrationBuilder.DropColumn( 26 | name: "MatchedAt", 27 | table: "PosterRecord"); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250212153309_MakePosterRequired.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Jellyfin.Plugin.LocalPosters; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | 9 | #nullable disable 10 | 11 | namespace Jellyfin.Plugin.LocalPosters.Migrations 12 | { 13 | [DbContext(typeof(Context))] 14 | [Migration("20250212153309_MakePosterRequired")] 15 | partial class MakePosterRequired 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); 22 | 23 | modelBuilder.Entity("Jellyfin.Plugin.LocalPosters.Entities.PosterRecord", b => 24 | { 25 | b.Property("Id") 26 | .ValueGeneratedOnAdd() 27 | .HasColumnType("TEXT"); 28 | 29 | b.Property("CreatedAt") 30 | .HasColumnType("TEXT"); 31 | 32 | b.Property("MatchedAt") 33 | .HasColumnType("TEXT"); 34 | 35 | b.Property("_posterPath") 36 | .IsRequired() 37 | .HasColumnType("TEXT") 38 | .HasColumnName("PosterPath"); 39 | 40 | b.HasKey("Id"); 41 | 42 | b.ToTable("PosterRecord"); 43 | }); 44 | #pragma warning restore 612, 618 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250212153309_MakePosterRequired.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.Migrations 6 | { 7 | /// 8 | public partial class MakePosterRequired : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.AlterColumn( 14 | name: "PosterPath", 15 | table: "PosterRecord", 16 | type: "TEXT", 17 | nullable: false, 18 | defaultValue: "", 19 | oldClrType: typeof(string), 20 | oldType: "TEXT", 21 | oldNullable: true); 22 | } 23 | 24 | /// 25 | protected override void Down(MigrationBuilder migrationBuilder) 26 | { 27 | migrationBuilder.AlterColumn( 28 | name: "PosterPath", 29 | table: "PosterRecord", 30 | type: "TEXT", 31 | nullable: true, 32 | oldClrType: typeof(string), 33 | oldType: "TEXT"); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250226102653_MoreFields.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Jellyfin.Plugin.LocalPosters; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | 9 | #nullable disable 10 | 11 | namespace Jellyfin.Plugin.LocalPosters.Migrations 12 | { 13 | [DbContext(typeof(Context))] 14 | [Migration("20250226102653_MoreFields")] 15 | partial class MoreFields 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); 22 | 23 | modelBuilder.Entity("Jellyfin.Plugin.LocalPosters.Entities.PosterRecord", b => 24 | { 25 | b.Property("Id") 26 | .ValueGeneratedOnAdd() 27 | .HasColumnType("TEXT"); 28 | 29 | b.Property("CreatedAt") 30 | .HasColumnType("TEXT"); 31 | 32 | b.Property("ImageType") 33 | .IsRequired() 34 | .ValueGeneratedOnAdd() 35 | .HasColumnType("TEXT") 36 | .HasDefaultValue("Primary"); 37 | 38 | b.Property("ItemKind") 39 | .IsRequired() 40 | .HasColumnType("TEXT"); 41 | 42 | b.Property("MatchedAt") 43 | .HasColumnType("TEXT"); 44 | 45 | b.Property("_posterPath") 46 | .IsRequired() 47 | .HasColumnType("TEXT") 48 | .HasColumnName("PosterPath"); 49 | 50 | b.HasKey("Id"); 51 | 52 | b.ToTable("PosterRecord"); 53 | }); 54 | #pragma warning restore 612, 618 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250226102653_MoreFields.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.Migrations 6 | { 7 | /// 8 | public partial class MoreFields : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.AddColumn( 14 | name: "ImageType", 15 | table: "PosterRecord", 16 | type: "TEXT", 17 | nullable: false, 18 | defaultValue: "Primary"); 19 | 20 | migrationBuilder.AddColumn( 21 | name: "ItemKind", 22 | table: "PosterRecord", 23 | type: "TEXT", 24 | nullable: false, 25 | defaultValue: ""); 26 | } 27 | 28 | /// 29 | protected override void Down(MigrationBuilder migrationBuilder) 30 | { 31 | migrationBuilder.DropColumn( 32 | name: "ImageType", 33 | table: "PosterRecord"); 34 | 35 | migrationBuilder.DropColumn( 36 | name: "ItemKind", 37 | table: "PosterRecord"); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250228102807_AddRowVersion.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Jellyfin.Plugin.LocalPosters; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | 9 | #nullable disable 10 | 11 | namespace Jellyfin.Plugin.LocalPosters.Migrations 12 | { 13 | [DbContext(typeof(Context))] 14 | [Migration("20250228102807_AddRowVersion")] 15 | partial class AddRowVersion 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); 22 | 23 | modelBuilder.Entity("Jellyfin.Plugin.LocalPosters.Entities.PosterRecord", b => 24 | { 25 | b.Property("Id") 26 | .ValueGeneratedOnAdd() 27 | .HasColumnType("TEXT"); 28 | 29 | b.Property("CreatedAt") 30 | .HasColumnType("TEXT"); 31 | 32 | b.Property("ImageType") 33 | .IsRequired() 34 | .ValueGeneratedOnAdd() 35 | .HasColumnType("TEXT") 36 | .HasDefaultValue("Primary"); 37 | 38 | b.Property("ItemKind") 39 | .IsRequired() 40 | .HasColumnType("TEXT"); 41 | 42 | b.Property("MatchedAt") 43 | .HasColumnType("TEXT"); 44 | 45 | b.Property("RowVersion") 46 | .IsConcurrencyToken() 47 | .ValueGeneratedOnAddOrUpdate() 48 | .HasColumnType("BLOB"); 49 | 50 | b.Property("_posterPath") 51 | .IsRequired() 52 | .HasColumnType("TEXT") 53 | .HasColumnName("PosterPath"); 54 | 55 | b.HasKey("Id"); 56 | 57 | b.ToTable("PosterRecord"); 58 | }); 59 | #pragma warning restore 612, 618 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250228102807_AddRowVersion.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.Migrations 6 | { 7 | /// 8 | public partial class AddRowVersion : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.AddColumn( 14 | name: "RowVersion", 15 | table: "PosterRecord", 16 | type: "BLOB", 17 | rowVersion: true, 18 | nullable: true); 19 | } 20 | 21 | /// 22 | protected override void Down(MigrationBuilder migrationBuilder) 23 | { 24 | migrationBuilder.DropColumn( 25 | name: "RowVersion", 26 | table: "PosterRecord"); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250309185708_ChangePrimaryKey.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Jellyfin.Plugin.LocalPosters; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | 9 | #nullable disable 10 | 11 | namespace Jellyfin.Plugin.LocalPosters.Migrations 12 | { 13 | [DbContext(typeof(Context))] 14 | [Migration("20250309185708_ChangePrimaryKey")] 15 | partial class ChangePrimaryKey 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); 22 | 23 | modelBuilder.Entity("Jellyfin.Plugin.LocalPosters.Entities.PosterRecord", b => 24 | { 25 | b.Property("ItemId") 26 | .HasColumnType("TEXT"); 27 | 28 | b.Property("ImageType") 29 | .ValueGeneratedOnAdd() 30 | .HasColumnType("TEXT") 31 | .HasDefaultValue("Primary"); 32 | 33 | b.Property("CreatedAt") 34 | .HasColumnType("TEXT"); 35 | 36 | b.Property("ItemKind") 37 | .IsRequired() 38 | .HasColumnType("TEXT"); 39 | 40 | b.Property("MatchedAt") 41 | .HasColumnType("TEXT"); 42 | 43 | b.Property("RowVersion") 44 | .IsConcurrencyToken() 45 | .ValueGeneratedOnAddOrUpdate() 46 | .HasColumnType("BLOB"); 47 | 48 | b.Property("_posterPath") 49 | .IsRequired() 50 | .HasColumnType("TEXT") 51 | .HasColumnName("PosterPath"); 52 | 53 | b.HasKey("ItemId", "ImageType"); 54 | 55 | b.ToTable("PosterRecord"); 56 | }); 57 | #pragma warning restore 612, 618 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/20250309185708_ChangePrimaryKey.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace Jellyfin.Plugin.LocalPosters.Migrations 7 | { 8 | /// 9 | [SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments")] 10 | public partial class ChangePrimaryKey : Migration 11 | { 12 | /// 13 | protected override void Up(MigrationBuilder migrationBuilder) 14 | { 15 | migrationBuilder.DropPrimaryKey( 16 | name: "PK_PosterRecord", 17 | table: "PosterRecord"); 18 | 19 | migrationBuilder.RenameColumn( 20 | name: "Id", 21 | table: "PosterRecord", 22 | newName: "ItemId"); 23 | 24 | migrationBuilder.AddPrimaryKey( 25 | name: "PK_PosterRecord", 26 | table: "PosterRecord", 27 | columns: new[] { "ItemId", "ImageType" }); 28 | } 29 | 30 | /// 31 | protected override void Down(MigrationBuilder migrationBuilder) 32 | { 33 | migrationBuilder.DropPrimaryKey( 34 | name: "PK_PosterRecord", 35 | table: "PosterRecord"); 36 | 37 | migrationBuilder.RenameColumn( 38 | name: "ItemId", 39 | table: "PosterRecord", 40 | newName: "Id"); 41 | 42 | migrationBuilder.AddPrimaryKey( 43 | name: "PK_PosterRecord", 44 | table: "PosterRecord", 45 | column: "Id"); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Migrations/ContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Jellyfin.Plugin.LocalPosters; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | 8 | #nullable disable 9 | 10 | namespace Jellyfin.Plugin.LocalPosters.Migrations 11 | { 12 | [DbContext(typeof(Context))] 13 | partial class ContextModelSnapshot : ModelSnapshot 14 | { 15 | protected override void BuildModel(ModelBuilder modelBuilder) 16 | { 17 | #pragma warning disable 612, 618 18 | modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); 19 | 20 | modelBuilder.Entity("Jellyfin.Plugin.LocalPosters.Entities.PosterRecord", b => 21 | { 22 | b.Property("ItemId") 23 | .HasColumnType("TEXT"); 24 | 25 | b.Property("ImageType") 26 | .ValueGeneratedOnAdd() 27 | .HasColumnType("TEXT") 28 | .HasDefaultValue("Primary"); 29 | 30 | b.Property("CreatedAt") 31 | .HasColumnType("TEXT"); 32 | 33 | b.Property("ItemKind") 34 | .IsRequired() 35 | .HasColumnType("TEXT"); 36 | 37 | b.Property("MatchedAt") 38 | .HasColumnType("TEXT"); 39 | 40 | b.Property("RowVersion") 41 | .IsConcurrencyToken() 42 | .ValueGeneratedOnAddOrUpdate() 43 | .HasColumnType("BLOB"); 44 | 45 | b.Property("_posterPath") 46 | .IsRequired() 47 | .HasColumnType("TEXT") 48 | .HasColumnName("PosterPath"); 49 | 50 | b.HasKey("ItemId", "ImageType"); 51 | 52 | b.ToTable("PosterRecord"); 53 | }); 54 | #pragma warning restore 612, 618 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/PluginServiceRegistrator.cs: -------------------------------------------------------------------------------- 1 | using Google.Apis.Util.Store; 2 | using Jellyfin.Plugin.LocalPosters.Configuration; 3 | using Jellyfin.Plugin.LocalPosters.Entities; 4 | using Jellyfin.Plugin.LocalPosters.GDrive; 5 | using Jellyfin.Plugin.LocalPosters.Matchers; 6 | using Jellyfin.Plugin.LocalPosters.Providers; 7 | using Jellyfin.Plugin.LocalPosters.ScheduledTasks; 8 | using Jellyfin.Plugin.LocalPosters.Utils; 9 | using MediaBrowser.Controller; 10 | using MediaBrowser.Controller.Plugins; 11 | using MediaBrowser.Model.IO; 12 | using Microsoft.EntityFrameworkCore; 13 | using Microsoft.Extensions.Caching.Memory; 14 | using Microsoft.Extensions.DependencyInjection; 15 | using Microsoft.Extensions.Logging; 16 | 17 | namespace Jellyfin.Plugin.LocalPosters; 18 | 19 | /// 20 | /// 21 | /// 22 | public class PluginServiceRegistrator : IPluginServiceRegistrator 23 | { 24 | /// 25 | public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) 26 | { 27 | serviceCollection.AddDbContext((p, builder) => 28 | { 29 | var plugin = p.GetRequiredService(); 30 | builder.UseSqlite($"Data Source={plugin.DbPath}"); 31 | }); 32 | serviceCollection.AddSingleton(); 33 | serviceCollection.AddSingleton(_ => 34 | { 35 | ArgumentNullException.ThrowIfNull(LocalPostersPlugin.Instance); 36 | return LocalPostersPlugin.Instance; 37 | }); 38 | serviceCollection.AddScoped(GetDbSet); 39 | serviceCollection.AddScoped(GetQueryable); 40 | serviceCollection.AddScoped(); 41 | serviceCollection.AddSingleton(); 42 | serviceCollection.AddScoped(provider => 43 | new CachedImageSearcher(provider.GetRequiredService(), provider.GetRequiredService(), 44 | provider.GetRequiredService(), provider.GetRequiredService())); 45 | serviceCollection.AddScoped(p => p.GetRequiredService().Configuration); 46 | serviceCollection.AddScoped(CreateBorderReplacer); 47 | serviceCollection.AddSingleton(provider => 48 | new FileDataStore(provider.GetRequiredService().GDriveTokenFolder, true)); 49 | serviceCollection.AddScoped(CreateSyncClients); 50 | serviceCollection.AddScoped(); 51 | serviceCollection.AddKeyedScoped(GDriveSyncClient.DownloadLimiterKey, GetGDriveDownloadLimiter); 52 | serviceCollection.AddKeyedSingleton(Constants.ScheduledTaskLockKey, new SemaphoreSlim(1)); 53 | serviceCollection.AddSingleton(); 54 | } 55 | 56 | static SemaphoreSlim GetGDriveDownloadLimiter(IServiceProvider serviceProvider, object _) 57 | { 58 | var configuration = serviceProvider.GetRequiredService(); 59 | return new SemaphoreSlim(configuration.ConcurrentDownloadLimit); 60 | } 61 | 62 | static IEnumerable CreateSyncClients(IServiceProvider serviceProvider) 63 | { 64 | var configuration = serviceProvider.GetRequiredService(); 65 | 66 | if (!configuration.Folders.Any(x => x.IsRemote)) 67 | yield break; 68 | 69 | var driveServiceProvider = serviceProvider.GetRequiredService(); 70 | var loggerFactory = serviceProvider.GetRequiredService(); 71 | var fileSystem = serviceProvider.GetRequiredService(); 72 | var downloadLimiter = serviceProvider.GetRequiredKeyedService(GDriveSyncClient.DownloadLimiterKey); 73 | 74 | foreach (var folder in configuration.Folders) 75 | { 76 | if (!folder.IsRemote) 77 | continue; 78 | 79 | if (string.IsNullOrEmpty(folder.LocalPath)) 80 | continue; 81 | 82 | yield return new GDriveSyncClient(loggerFactory.CreateLogger($"{nameof(GDriveSyncClient)}[{folder.RemoteId}]"), 83 | driveServiceProvider, folder.RemoteId, 84 | folder.LocalPath, 85 | fileSystem, downloadLimiter); 86 | } 87 | } 88 | 89 | static DbSet GetDbSet(IServiceProvider provider) where T : class 90 | { 91 | return provider.GetRequiredService().Set(); 92 | } 93 | 94 | static IQueryable GetQueryable(IServiceProvider provider) where T : class 95 | { 96 | return provider.GetRequiredService().Set().AsNoTracking(); 97 | } 98 | 99 | static IImageProcessor CreateBorderReplacer(IServiceProvider provider) 100 | { 101 | var pluginConfiguration = provider.GetRequiredService(); 102 | var resizer = pluginConfiguration.ResizeImage 103 | ? new SkiaImageResizer(provider.GetRequiredService()) 104 | : NoopImageResizer.Instance.Value; 105 | 106 | if (!pluginConfiguration.EnableBorderReplacer) 107 | return resizer; 108 | 109 | if (pluginConfiguration.RemoveBorder || !pluginConfiguration.SkColor.HasValue) 110 | return new SkiaSharpBorderRemover(resizer); 111 | 112 | if (pluginConfiguration.SkColor.HasValue) 113 | return new SkiaSharpImageProcessor(pluginConfiguration.SkColor.Value, resizer); 114 | 115 | return resizer; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Providers/LocalImageProvider.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.LocalPosters.Entities; 2 | using Jellyfin.Plugin.LocalPosters.Matchers; 3 | using Jellyfin.Plugin.LocalPosters.Utils; 4 | using MediaBrowser.Controller.Entities; 5 | using MediaBrowser.Controller.Providers; 6 | using MediaBrowser.Model.Drawing; 7 | using MediaBrowser.Model.Entities; 8 | using MediaBrowser.Model.MediaInfo; 9 | using Microsoft.Extensions.DependencyInjection; 10 | 11 | namespace Jellyfin.Plugin.LocalPosters.Providers; 12 | 13 | using static File; 14 | 15 | /// 16 | /// 17 | /// 18 | public class LocalImageProvider( 19 | IServiceScopeFactory serviceScopeFactory, 20 | TimeProvider timeProvider, 21 | IImageSearcher searcher) 22 | : IDynamicImageProvider, IHasOrder 23 | { 24 | /// 25 | public bool Supports(BaseItem item) 26 | { 27 | return searcher.IsSupported(item); 28 | } 29 | 30 | /// 31 | public string Name => LocalPostersPlugin.ProviderName; 32 | 33 | /// 34 | public IEnumerable GetSupportedImages(BaseItem item) 35 | { 36 | return searcher.SupportedImages(item); 37 | } 38 | 39 | /// 40 | public async Task GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) 41 | { 42 | cancellationToken.ThrowIfCancellationRequested(); 43 | 44 | await using var serviceScope = serviceScopeFactory.CreateAsyncScope(); 45 | 46 | var context = serviceScope.ServiceProvider.GetRequiredService(); 47 | var dbSet = context.Set(); 48 | var record = await dbSet.FindAsync([item.Id, type], cancellationToken: cancellationToken) 49 | .ConfigureAwait(false); 50 | 51 | var file = searcher.Search(type, item, cancellationToken); 52 | 53 | if (!file.Exists) 54 | { 55 | if (record == null) 56 | return ValueCache.Empty.Value; 57 | 58 | // remove existing record if were not able to match 59 | dbSet.Remove(record); 60 | await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); 61 | 62 | return ValueCache.Empty.Value; 63 | } 64 | 65 | var now = timeProvider.GetLocalNow(); 66 | if (record == null) 67 | { 68 | record = new PosterRecord(item, type, now, file); 69 | record.SetPosterFile(file, now); 70 | await dbSet.AddAsync(record, cancellationToken).ConfigureAwait(false); 71 | } 72 | else 73 | { 74 | record.SetPosterFile(file, now); 75 | dbSet.Update(record); 76 | } 77 | 78 | await using var stream = OpenRead(file.FullName); 79 | var imageProcessor = serviceScope.ServiceProvider.GetRequiredService(); 80 | 81 | var response = new DynamicImageResponse 82 | { 83 | Stream = imageProcessor.Process(item.GetBaseItemKind(), type, stream), 84 | HasImage = true, 85 | Format = ImageFormat.Jpg, 86 | Protocol = MediaProtocol.File 87 | }; 88 | 89 | await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); 90 | 91 | return response; 92 | } 93 | 94 | /// 95 | public int Order => 1; 96 | } 97 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Providers/ValueCache.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Providers; 2 | 3 | namespace Jellyfin.Plugin.LocalPosters.Providers; 4 | 5 | /// 6 | /// 7 | /// 8 | public static class ValueCache 9 | { 10 | /// 11 | /// 12 | /// 13 | public static readonly Lazy Empty = new(() => new DynamicImageResponse { HasImage = false }); 14 | } 15 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/ScheduledTasks/CleanupTask.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.LocalPosters.Entities; 2 | using MediaBrowser.Model.Tasks; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace Jellyfin.Plugin.LocalPosters.ScheduledTasks; 7 | 8 | /// 9 | /// 10 | /// 11 | public class CleanupTask( 12 | IServiceScopeFactory serviceScopeFactory, 13 | [FromKeyedServices(Constants.ScheduledTaskLockKey)] 14 | SemaphoreSlim executionLock) : IScheduledTask 15 | { 16 | /// 17 | public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) 18 | { 19 | progress.Report(0); 20 | 21 | await executionLock.WaitAsync(cancellationToken).ConfigureAwait(false); 22 | try 23 | { 24 | await using var scope = serviceScopeFactory.CreateAsyncScope(); 25 | var context = scope.ServiceProvider.GetRequiredService(); 26 | await context.Set().ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); 27 | 28 | progress.Report(100); 29 | } 30 | finally 31 | { 32 | executionLock.Release(); 33 | } 34 | } 35 | 36 | /// 37 | public IEnumerable GetDefaultTriggers() 38 | { 39 | return []; 40 | } 41 | 42 | /// 43 | public string Name => "Cleanup local posters db"; 44 | 45 | /// 46 | public string Key => "CleanupLocalPostersDB"; 47 | 48 | /// 49 | public string Description => "Cleanup local posters database"; 50 | 51 | /// 52 | public string Category => "Local Posters"; 53 | } 54 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/ScheduledTasks/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace Jellyfin.Plugin.LocalPosters.ScheduledTasks; 2 | 3 | /// 4 | /// 5 | /// 6 | public static class Constants 7 | { 8 | /// 9 | /// 10 | /// 11 | public const string ScheduledTaskLockKey = "ScheduledTaskLock"; 12 | } 13 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/ScheduledTasks/SyncGDriveTask.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.LocalPosters.Configuration; 2 | using Jellyfin.Plugin.LocalPosters.GDrive; 3 | using MediaBrowser.Model.Tasks; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace Jellyfin.Plugin.LocalPosters.ScheduledTasks; 8 | 9 | /// 10 | /// 11 | /// 12 | public class SyncGDriveTask( 13 | ILogger logger, 14 | IServiceScopeFactory serviceScopeFactory, 15 | ITaskManager manager, 16 | [FromKeyedServices(Constants.ScheduledTaskLockKey)] 17 | SemaphoreSlim executionLock) : IScheduledTask 18 | { 19 | private readonly object _sync = new(); 20 | 21 | /// 22 | public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) 23 | { 24 | progress.Report(0); 25 | 26 | await executionLock.WaitAsync(cancellationToken).ConfigureAwait(false); 27 | try 28 | { 29 | await using var scope = serviceScopeFactory.CreateAsyncScope(); 30 | var syncClients = scope.ServiceProvider.GetRequiredService>().ToArray(); 31 | var configuration = scope.ServiceProvider.GetRequiredService(); 32 | var totalClients = syncClients.Length; 33 | 34 | logger.LogInformation("Syncing GDrive {FoldersCount} folders using {NumThreads} threads", totalClients, 35 | configuration.ConcurrentDownloadLimit); 36 | 37 | var tasks = new List(totalClients); 38 | var trackers = new double[totalClients]; 39 | var totalSum = 0.0; 40 | long totalItems = 0; 41 | for (var index = 0; index < totalClients; index += 1) 42 | { 43 | var i = index; 44 | var itemProgress = new Progress(d => 45 | { 46 | lock (_sync) 47 | { 48 | trackers[i] = Math.Max(d, trackers[i]); 49 | totalSum = Math.Max(totalSum, trackers.Sum()); 50 | } 51 | 52 | progress.Report((totalSum / 100) * (90d / totalClients)); 53 | }); 54 | var syncClient = syncClients[index]; 55 | 56 | tasks.Add(Task.Run(async () => 57 | { 58 | var itemsCount = await syncClient.SyncAsync(itemProgress, cancellationToken).ConfigureAwait(false); 59 | Interlocked.Add(ref totalItems, itemsCount); 60 | }, cancellationToken)); 61 | } 62 | 63 | await Task.WhenAll(tasks).ConfigureAwait(false); 64 | 65 | progress.Report(99); 66 | 67 | if (totalItems > 0) 68 | { 69 | logger.LogInformation("{Items} new items were downloaded, scheduling UpdateTask", totalItems); 70 | manager.CancelIfRunningAndQueue(); 71 | } 72 | 73 | progress.Report(100); 74 | } 75 | finally 76 | { 77 | executionLock.Release(); 78 | } 79 | } 80 | 81 | /// 82 | public IEnumerable GetDefaultTriggers() 83 | { 84 | return [new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerDaily, TimeOfDayTicks = TimeSpan.FromHours(0).Ticks }]; 85 | } 86 | 87 | /// 88 | public string Name => "Sync Posters with GDrive"; 89 | 90 | /// 91 | public string Key => "SyncLocalPostersFromGDrive"; 92 | 93 | /// 94 | public string Description => "Sync Local Posters with GDrive"; 95 | 96 | /// 97 | public string Category => "Local Posters"; 98 | } 99 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/ScheduledTasks/UpdateTask.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Channels; 2 | using Jellyfin.Plugin.LocalPosters.Entities; 3 | using Jellyfin.Plugin.LocalPosters.Matchers; 4 | using Jellyfin.Plugin.LocalPosters.Providers; 5 | using Jellyfin.Plugin.LocalPosters.Utils; 6 | using MediaBrowser.Controller.Entities; 7 | using MediaBrowser.Controller.Library; 8 | using MediaBrowser.Controller.Providers; 9 | using MediaBrowser.Model.Drawing; 10 | using MediaBrowser.Model.Entities; 11 | using MediaBrowser.Model.Tasks; 12 | using Microsoft.EntityFrameworkCore; 13 | using Microsoft.Extensions.DependencyInjection; 14 | using Microsoft.Extensions.Logging; 15 | 16 | namespace Jellyfin.Plugin.LocalPosters.ScheduledTasks; 17 | 18 | /// 19 | /// 20 | /// 21 | public class UpdateTask( 22 | ILibraryManager libraryManager, 23 | ILogger logger, 24 | LocalImageProvider localImageProvider, 25 | IProviderManager providerManager, 26 | IDirectoryService directoryService, 27 | IServiceScopeFactory serviceScopeFactory, 28 | IMatcherFactory matcherFactory, 29 | [FromKeyedServices(Constants.ScheduledTaskLockKey)] 30 | SemaphoreSlim executionLock) : IScheduledTask 31 | { 32 | private readonly object _lock = new(); 33 | 34 | /// 35 | public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) 36 | { 37 | progress.Report(0); 38 | 39 | await executionLock.WaitAsync(cancellationToken).ConfigureAwait(false); 40 | 41 | try 42 | { 43 | await using var scope = serviceScopeFactory.CreateAsyncScope(); 44 | var context = scope.ServiceProvider.GetRequiredService(); 45 | var dbSet = context.Set(); 46 | 47 | var count = libraryManager.GetCount(new InternalItemsQuery { IncludeItemTypes = [..matcherFactory.SupportedItemKinds] }); 48 | 49 | var ids = new HashSet(await dbSet.AsTracking().Select(x => x.ItemId).ToListAsync(cancellationToken) 50 | .ConfigureAwait(false)); 51 | 52 | var currentProgress = 0d; 53 | const int BatchSize = 5000; 54 | 55 | var channel = Channel.CreateBounded>>(new BoundedChannelOptions(BatchSize) 56 | { 57 | FullMode = BoundedChannelFullMode.Wait, SingleReader = false, SingleWriter = true, 58 | }); 59 | 60 | var increaseInProgress = (20 - currentProgress) / (Math.Max(1, count / (double)BatchSize)); 61 | var items = new Dictionary>(); 62 | for (var startIndex = 0; startIndex < count; startIndex += BatchSize) 63 | { 64 | var library = libraryManager.GetItemList(new InternalItemsQuery 65 | { 66 | IncludeItemTypes = [..matcherFactory.SupportedItemKinds], 67 | StartIndex = startIndex, 68 | Limit = BatchSize, 69 | SkipDeserialization = true 70 | }); 71 | 72 | var libraryIds = new HashSet(library.Select(x => x.Id)); 73 | 74 | var records = await dbSet.AsNoTracking().Where(x => libraryIds.Contains(x.ItemId)) 75 | .Select(x => new { x.ItemId, x.ImageType }) 76 | .GroupBy(x => x.ItemId, x => x.ImageType) 77 | .ToDictionaryAsync(x => x.Key, x => x.ToHashSet(), cancellationToken) 78 | .ConfigureAwait(false); 79 | 80 | foreach (var item in library) 81 | { 82 | foreach (var imageType in matcherFactory.SupportedImageTypes(item)) 83 | { 84 | cancellationToken.ThrowIfCancellationRequested(); 85 | 86 | if (records.TryGetValue(item.Id, out var types) && types.Contains(imageType)) 87 | continue; 88 | 89 | var imageRefreshOptions = new ImageRefreshOptions(directoryService) 90 | { 91 | ImageRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceImages = [imageType] 92 | }; 93 | 94 | if (!providerManager.HasImageProviderEnabled(item, imageRefreshOptions)) 95 | continue; 96 | 97 | if (items.TryGetValue(item.Id, out var images)) 98 | images.Add(imageType); 99 | else 100 | items[item.Id] = [imageType]; 101 | } 102 | 103 | ids.Remove(item.Id); 104 | } 105 | 106 | progress.Report(currentProgress += increaseInProgress); 107 | } 108 | 109 | var totalImages = items.Values.Sum(x => x.Count); 110 | increaseInProgress = (95 - currentProgress) / totalImages; 111 | 112 | var concurrencyLimit = Environment.ProcessorCount; 113 | var readerTask = StartReaders(channel.Reader); 114 | 115 | logger.LogInformation("Starting matching for {ItemsCount} unique items, and total: {TotalCount} using {NumThreads} threads", 116 | items.Count, totalImages, concurrencyLimit); 117 | 118 | foreach (var tuple in items) 119 | await channel.Writer.WriteAsync(tuple, cancellationToken).ConfigureAwait(false); 120 | 121 | channel.Writer.Complete(); 122 | 123 | var removed = 0; 124 | while (ids.Count > 0) 125 | { 126 | var slice = new HashSet(ids.Take(BatchSize)); 127 | removed += await RemoveItems(slice).ConfigureAwait(false); 128 | 129 | foreach (var itemToRemove in slice) 130 | ids.Remove(itemToRemove); 131 | } 132 | 133 | await readerTask.ConfigureAwait(false); 134 | 135 | if (removed > 0) 136 | logger.LogInformation("{ItemsCount} items were removed from db, as nonexistent inside the library.", removed); 137 | 138 | progress.Report(100d); 139 | return; 140 | 141 | async Task RemoveItems(HashSet slice) 142 | { 143 | var itemsToRemove = await dbSet.Where(x => slice.Contains(x.ItemId)) 144 | .ToArrayAsync(cancellationToken) 145 | .ConfigureAwait(false); 146 | dbSet.RemoveRange(itemsToRemove); 147 | return await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); 148 | } 149 | 150 | Task StartReaders(ChannelReader>> reader) 151 | { 152 | #pragma warning disable IDISP013 153 | return Task.WhenAll(Enumerable.Range(0, concurrencyLimit).Select(_ => Task.Run(ReaderTask, cancellationToken))); 154 | #pragma warning restore IDISP013 155 | async Task ReaderTask() 156 | { 157 | while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) 158 | { 159 | while (reader.TryRead(out var i)) 160 | { 161 | await ProcessItem(i.Key, i.Value).ConfigureAwait(false); 162 | 163 | if (!await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) 164 | break; 165 | 166 | continue; 167 | 168 | async Task ProcessItem(Guid id, HashSet types) 169 | { 170 | cancellationToken.ThrowIfCancellationRequested(); 171 | 172 | var item = libraryManager.GetItemById(id); 173 | if (item == null) 174 | return; 175 | 176 | foreach (var imageType in types) 177 | { 178 | var image = await localImageProvider.GetImage(item, imageType, cancellationToken).ConfigureAwait(false); 179 | if (image.HasImage) 180 | await providerManager.SaveImage(item, image.Stream, image.Format.GetMimeType(), imageType, null, 181 | cancellationToken).ConfigureAwait(false); 182 | 183 | lock (_lock) 184 | progress.Report(currentProgress += increaseInProgress); 185 | } 186 | } 187 | } 188 | } 189 | } 190 | } 191 | } 192 | finally 193 | { 194 | executionLock.Release(); 195 | } 196 | } 197 | 198 | /// 199 | public IEnumerable GetDefaultTriggers() 200 | { 201 | return [new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerDaily, TimeOfDayTicks = TimeSpan.FromHours(0).Ticks }]; 202 | } 203 | 204 | /// 205 | public string Name => "Match and Update local posters"; 206 | 207 | /// 208 | public string Key => "MatchAndUpdateLocalPosters"; 209 | 210 | /// 211 | public string Description => "Update posters using local library"; 212 | 213 | /// 214 | public string Category => "Local Posters"; 215 | } 216 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Utils/IImageProcessor.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Data.Enums; 2 | using MediaBrowser.Model.Entities; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Utils; 5 | 6 | /// 7 | /// 8 | /// 9 | public interface IImageProcessor 10 | { 11 | /// 12 | /// 13 | /// 14 | /// 15 | /// 16 | /// 17 | /// 18 | Stream Process(BaseItemKind kind, ImageType imageType, Stream stream); 19 | } 20 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Utils/ImageSizeProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Drawing; 2 | using Jellyfin.Data.Enums; 3 | using MediaBrowser.Model.Entities; 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.Utils; 6 | 7 | public class ImageSizeProvider 8 | { 9 | public Size GetImageSize(BaseItemKind kind, ImageType imageType) 10 | { 11 | return imageType switch 12 | { 13 | ImageType.Primary => kind switch 14 | { 15 | BaseItemKind.BoxSet or BaseItemKind.Movie or BaseItemKind.Series or BaseItemKind.Season => new Size(1000, 1500), 16 | BaseItemKind.Episode => new Size(1920, 1080), 17 | _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) 18 | }, 19 | ImageType.Backdrop => new Size(1920, 1080), 20 | ImageType.Art => new Size(500, 281), 21 | ImageType.Banner => new Size(1000, 185), 22 | ImageType.Thumb => new Size(1000, 562), 23 | ImageType.Logo => new Size(800, 310), 24 | ImageType.Disc => new Size(1000, 1000), 25 | _ => throw new NotSupportedException("Not supported image type") 26 | }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Utils/NoopImageResizer.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Data.Enums; 2 | using MediaBrowser.Model.Entities; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Utils; 5 | 6 | public class NoopImageResizer : IImageProcessor 7 | { 8 | public static readonly Lazy Instance = new(() => new NoopImageResizer()); 9 | 10 | private NoopImageResizer() 11 | { 12 | } 13 | 14 | public Stream Process(BaseItemKind kind, ImageType imageType, Stream stream) 15 | { 16 | var memoryStream = new MemoryStream(); 17 | stream.CopyTo(memoryStream); 18 | memoryStream.Seek(0, SeekOrigin.Begin); 19 | return memoryStream; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Utils/ProviderManagerExtensions.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Entities; 2 | using MediaBrowser.Controller.Providers; 3 | 4 | namespace Jellyfin.Plugin.LocalPosters.Utils; 5 | 6 | public static class ProviderManagerExtensions 7 | { 8 | public static bool HasImageProviderEnabled(this IProviderManager manager, BaseItem item, ImageRefreshOptions refreshOptions) 9 | { 10 | var providers = manager.GetImageProviders(item, refreshOptions); 11 | return providers.Any(x => x.Name == LocalPostersPlugin.ProviderName); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Utils/SkiaImageResizer.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Data.Enums; 2 | using MediaBrowser.Model.Entities; 3 | using SkiaSharp; 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.Utils; 6 | 7 | /// 8 | /// 9 | /// 10 | public class SkiaImageResizer(ImageSizeProvider sizeProvider) : IImageProcessor 11 | { 12 | /// 13 | public Stream Process(BaseItemKind kind, ImageType imageType, Stream stream) 14 | { 15 | using var bitmap = SKBitmap.Decode(stream); 16 | 17 | var size = sizeProvider.GetImageSize(kind, imageType); 18 | using var resizedImage = new SKBitmap(size.Width, size.Height); 19 | using var canvas = new SKCanvas(resizedImage); 20 | canvas.DrawBitmap(bitmap, new SKRect(0, 0, resizedImage.Width, resizedImage.Height)); 21 | 22 | // Use MemoryStream to store the result 23 | var memoryStream = new MemoryStream(); 24 | resizedImage.Encode(memoryStream, SKEncodedImageFormat.Jpeg, 100); 25 | 26 | // Reset the memory stream position to the start before returning 27 | memoryStream.Seek(0, SeekOrigin.Begin); 28 | return memoryStream; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Utils/SkiaSharpBorderRemover.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Data.Enums; 2 | using MediaBrowser.Model.Entities; 3 | using SkiaSharp; 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.Utils; 6 | 7 | /// 8 | /// 9 | /// 10 | public class SkiaSharpBorderRemover(IImageProcessor next) : IImageProcessor 11 | { 12 | /// 13 | public Stream Process(BaseItemKind kind, ImageType imageType, Stream stream) 14 | { 15 | using var bitmap = SKBitmap.Decode(stream); 16 | int width = bitmap.Width; 17 | int height = bitmap.Height; 18 | 19 | using var cropped = new SKBitmap(width - 50, height - 25); 20 | using var canvas = new SKCanvas(cropped); 21 | // Copy cropped region 22 | canvas.DrawBitmap(bitmap, 23 | new SKRect(25, 25, width - 25, height), // Source (crop area) 24 | new SKRect(0, 0, width - 50, height - 25) // Destination 25 | ); 26 | 27 | // Draw black bottom border 28 | using var paint = new SKPaint(); 29 | paint.Color = SKColors.Black; 30 | canvas.DrawRect(0, cropped.Height - 25, width - 50, 25, paint); 31 | 32 | using var memoryStream = new MemoryStream(); 33 | cropped.Encode(memoryStream, SKEncodedImageFormat.Jpeg, 100); 34 | // Reset the memory stream position to the start before returning 35 | memoryStream.Seek(0, SeekOrigin.Begin); 36 | return next.Process(kind, imageType, memoryStream); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.LocalPosters/Utils/SkiaSharpImageProcessor.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Data.Enums; 2 | using MediaBrowser.Model.Entities; 3 | using SkiaSharp; 4 | 5 | namespace Jellyfin.Plugin.LocalPosters.Utils; 6 | 7 | /// 8 | /// 9 | /// 10 | public class SkiaSharpImageProcessor(SKColor color, IImageProcessor next) : IImageProcessor 11 | { 12 | /// 13 | public Stream Process(BaseItemKind kind, ImageType imageType, Stream stream) 14 | { 15 | using var bitmap = SKBitmap.Decode(stream); 16 | int width = bitmap.Width; 17 | int height = bitmap.Height; 18 | 19 | // Crop 25px from all sides 20 | using SKBitmap cropped = new SKBitmap(width - 50, height - 50); 21 | using (var canvas = new SKCanvas(cropped)) 22 | { 23 | canvas.DrawBitmap(bitmap, 24 | new SKRect(25, 25, width - 25, height - 25), // Source (crop area) 25 | new SKRect(0, 0, width - 50, height - 50) // Destination 26 | ); 27 | } 28 | 29 | // Create new image with custom color background 30 | using SKBitmap newImage = new SKBitmap(width, height); 31 | using (var canvas = new SKCanvas(newImage)) 32 | { 33 | canvas.Clear(color); // Fill background with custom color 34 | canvas.DrawBitmap(cropped, new SKPoint(25, 25)); // Paste cropped image 35 | 36 | using var memoryStream = new MemoryStream(); 37 | newImage.Encode(memoryStream, SKEncodedImageFormat.Jpeg, 100); 38 | // Reset the memory stream position to the start before returning 39 | memoryStream.Seek(0, SeekOrigin.Begin); 40 | return next.Process(kind, imageType, memoryStream); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jellyfin.Plugin.LocalPosters 2 | 3 | **Jellyfin.Plugin.LocalPosters** is a plugin for [Jellyfin](https://jellyfin.org/) that prioritizes the use of local poster images for your media library. This ensures that your personal artwork is displayed instead of automatically downloaded images. 4 | 5 | Inspired by: https://github.com/Drazzilb08/daps (So make sure to check the project) 6 | 7 | ### Currently supported 8 | Filename formats from [MediUX](https://mediux.pro/) or [TPDb](https://theposterdb.com/) 9 | 10 | Media Types: 11 | - `Movie`: you can test filename with: https://regex101.com/r/WwQIOJ/1 (make sure it matches jellyfin item metadata) 12 | - `BoxSet`: you can test filename with: https://regex101.com/r/9kPyq5/1 (make sure it matches jellyfin item metadata) 13 | - `Series`: you can test filename with: https://regex101.com/r/DQSHJF/2 (make sure it matches jellyfin item metadata) 14 | - `Season`: you can test filename with: https://regex101.com/r/RTYOdo/2 (make sure it matches jellyfin item metadata) 15 | - `Episode`: you can test filename with: https://regex101.com/r/oaKoZq/2 (make sure it matches jellyfin item metadata) 16 | 17 | ## Features 18 | 19 | - **Local Artwork Priority**: The plugin ensures that any locally stored poster images are used as the primary artwork, providing a personalized experience. 20 | - **GDrive Sync**: Sync GDrive folders using either (OAuth2 or Service Account) 21 | 22 | ![Screenshot 2025-02-17 at 15 38 08](https://github.com/user-attachments/assets/6a716f88-268d-4781-a2fb-cc1aefc723f3) 23 | 24 | 25 | 📌 Images shown are just a preview of the functionality and do not represent any local library 26 | 27 | ## Installation 28 | 29 | ### Using the Plugin Repository 30 | 31 | 1. Open Jellyfin and navigate to **Dashboard > Plugins**. 32 | 2. Click on **Repositories** and add the following URL: https://noonamer.github.io/Jellyfin.Plugin.LocalPosters/repository.json 33 | 3. Go to **Catalog**, find **Local Posters**, and install it. 34 | 4. Restart your Jellyfin server to apply the changes. 35 | 36 | ### Manual Installation 37 | 38 | 1. **Download the Plugin**: 39 | - Navigate to the [Releases](https://github.com/NooNameR/Jellyfin.Plugin.LocalPosters/releases) section of the repository. 40 | - Download the latest version of the plugin (`.dll` file). 41 | 42 | 2. **Install the Plugin**: 43 | - Place the downloaded `.dll` file into the `plugins` directory of your Jellyfin server. 44 | - Restart your Jellyfin server to recognize the new plugin. 45 | 46 | ## Configuration 47 | 48 | Once installed, you need to specify the folder locations where your posters are stored. `Local Posters` image fetcher should be enabled for desired libraries 49 | 50 | Additionally, you can modify poster borders using the **border replacer** feature. To enable and configure this, navigate to the plugin settings within Jellyfin. 51 | 52 | ### Creating Client Secrets for GDrive Integration 53 | To enable GDrive integration with `./auth/drive.file` scope: 54 | 1. Make sure [Known Proxy](https://jellyfin.org/docs/general/post-install/networking/#known-proxies) is set correctly before proceeding (Google unlikely to give refresh token to localhost) 55 | 2. **Google Cloud Console:** [console.cloud.google.com](https://console.cloud.google.com/) 56 | 3. **Create Project:** Click project dropdown, select **New Project**, and name it. 57 | 4. **Enable Google Drive API:** Navigate to **APIs & Services > Library** and enable **Google Drive API**. 58 | 5. **Create OAuth 2.0 Credentials:** 59 | - Under **APIs & Services > Credentials**, create **OAuth client ID**. 60 | - Configure consent screen (**External**, add app name, save). 61 | - Set **Authorized Redirect URI**: `{YOUR_JELLYFIN_ADDRESS}/LocalPosters/GoogleAuthorization/Callback`. NOTE: if you are using different addresses for local and external network you have to add both addresses. Make sure you initiate authorization from the whitelisted base address 62 | - Select **Web application**, download `client_secrets.json`. 63 | 6. **Set Scopes:** Add `https://www.googleapis.com/auth/drive.file`. 64 | 7. **Publish Application:** Publish the OAuth consent screen for external access. Please note if you use "Test Audience" refresh token will expire withing 7 days, with current scope application won't require "Verification" 65 | 8. **Upload Client Secrets:** Place `client_secrets.json` into directory visible for Jellyfin and change Plugin configuration accordingly. 66 | 67 | Once configuration is done, plugin will keep syncing folders and searching for missing images in the selected libraries, no manual interaction is required! 😊 68 | 69 | ## Support 70 | 71 | If you encounter any issues or have questions about the plugin, please open an [issue](https://github.com/NooNameR/Jellyfin.Plugin.LocalPosters/issues) in the GitHub Issues section of the repository. 72 | 73 | While GitHub issues are the preferred way to report a bug, feel free to join the discussion on [Trash Discord](https://discord.com/channels/492590071455940612/1342175843069329448). 74 | 75 | ## License 76 | 77 | This project is licensed under the MIT License. See the [LICENSE](https://github.com/NooNameR/Jellyfin.Plugin.LocalPosters/blob/master/LICENSE) file for details. 78 | -------------------------------------------------------------------------------- /build.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Local Posters" 3 | guid: "3938fe98-b7b2-4333-b678-c4c4e339d232" 4 | version: "0.1.1.10" 5 | targetAbi: "10.10.6.0" 6 | framework: "net8.0" 7 | overview: "Plugin to match local posters (similar to what Kometa is doing with Plex)" 8 | description: > 9 | This is a longer description that can span more than one 10 | line and include details about your plugin. 11 | category: "Metadata" 12 | owner: "jellyfin" 13 | artifacts: 14 | - "Jellyfin.Plugin.LocalPosters.dll" 15 | - "Google.Apis.Core.dll" 16 | - "Google.Apis.Auth.dll" 17 | - "Google.Apis.dll" 18 | - "Google.Apis.Drive.v3.dll" 19 | changelog: > 20 | changelog 21 | -------------------------------------------------------------------------------- /jellyfin.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | --------------------------------------------------------------------------------