├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ └── 刮削失败相关问题.md └── workflows │ ├── beta.yaml │ ├── build.yaml │ ├── issue_close_inactive.yml │ └── publish.yaml ├── .gitignore ├── AnitomySharp ├── Anitomy.cs ├── AnitomySharp.csproj ├── Element.cs ├── Keyword.cs ├── Options.cs ├── Parser.cs ├── ParserHelper.cs ├── ParserNumber.cs ├── StringHelper.cs ├── Token.cs ├── TokenRange.cs └── Tokenizer.cs ├── Jellyfin.Plugin.MetaShark.Test ├── BoxSetProviderTest.cs ├── DefaultHttpClientFactory.cs ├── DoubanApiTest.cs ├── EpisodeProviderTest.cs ├── ImdbApiTest.cs ├── Jellyfin.Plugin.MetaShark.Test.csproj ├── MovieImageProviderTest.cs ├── MovieProviderTest.cs ├── ParseNameTest.cs ├── PersonProviderTest.cs ├── SeasonProviderTest.cs ├── SeriesImageProviderTest.cs ├── SeriesProviderTest.cs ├── StringSimilarityTest.cs ├── TmdbApiTest.cs └── Usings.cs ├── Jellyfin.Plugin.MetaShark.sln ├── Jellyfin.Plugin.MetaShark ├── Api │ ├── DoubanApi.cs │ ├── Http │ │ ├── HttpClientHandlerEx.cs │ │ └── LoggingHandler.cs │ ├── ImdbApi.cs │ ├── OmdbApi.cs │ └── TmdbApi.cs ├── BoxSetManager.cs ├── Configuration │ ├── PluginConfiguration.cs │ └── configPage.html ├── Controllers │ └── ApiController.cs ├── Core │ ├── ElementExtension.cs │ ├── JsonExtension.cs │ ├── ListExtension.cs │ ├── NameParser.cs │ ├── RegexExtension.cs │ ├── StringExtension.cs │ ├── StringMetric │ │ └── JaroWinkler.cs │ └── Utils.cs ├── ILRepack.targets ├── Jellyfin.Plugin.MetaShark.csproj ├── Model │ ├── ApiResult.cs │ ├── DoubanLoginInfo.cs │ ├── DoubanSubject.cs │ ├── DoubanSuggest.cs │ ├── DoubanSuggestResult.cs │ ├── GuessInfo.cs │ ├── MetaSource.cs │ ├── OmdbItem.cs │ └── ParseNameResult.cs ├── Plugin.cs ├── Providers │ ├── BaseProvider.cs │ ├── BoxSetImageProvider.cs │ ├── BoxSetProvider.cs │ ├── EpisodeImageProvider.cs │ ├── EpisodeProvider.cs │ ├── Extensions │ │ ├── EnumerableExtensions.cs │ │ └── ProviderIdsExtensions.cs │ ├── ExternalId │ │ ├── DoubanExternalId.cs │ │ └── DoubanPersonExternalId.cs │ ├── MovieImageProvider.cs │ ├── MovieProvider.cs │ ├── PersonImageProvider.cs │ ├── PersonProvider.cs │ ├── SeasonImageProvider.cs │ ├── SeasonProvider.cs │ ├── SeriesImageProvider.cs │ └── SeriesProvider.cs ├── ScheduledTasks │ └── AutoCreateCollectionTask.cs └── ServiceRegistrator.cs ├── LICENSE ├── README.md ├── doc └── logo.png └── scripts └── generate_manifest.py /.gitattributes: -------------------------------------------------------------------------------- 1 | Jellyfin.Plugin.MetaShark/Vendor/** linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/刮削失败相关问题.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 刮削失败相关问题 3 | about: 报告刮削失败相关问题. 4 | title: "[刮削]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **描述错误** 11 | 12 | 对错误是什么的清晰简明描述。 13 | 14 | **屏幕截图** 15 | 16 | 请添加问题截图以帮助解释您的问题。 17 | 18 | **日志** 19 | 20 | 请提供jellyfin打印的该影片的刮削日志。 21 | 22 | 日志查看方法: 控制台->高级->日志->点击log_yyyymmdd.log格式文件 23 | 24 | **运行环境(请填写以下信息):** 25 | 26 | - 操作系统:[例如 linux] 27 | - jellyfin 版本:[例如 10.8.9] 28 | - 插件版本:[例如 1.7.1] 29 | -------------------------------------------------------------------------------- /.github/workflows/beta.yaml: -------------------------------------------------------------------------------- 1 | name: "🚀 Beta" 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | dotnet-version: 8.0.x 8 | python-version: 3.8 9 | project: Jellyfin.Plugin.MetaShark/Jellyfin.Plugin.MetaShark.csproj 10 | artifact: metashark 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | name: Build & Release 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Get tags (For CHANGELOG) 20 | run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* 21 | - name: Setup dotnet 22 | uses: actions/setup-dotnet@v3 23 | id: dotnet 24 | with: 25 | dotnet-version: ${{ env.dotnet-version }} 26 | - name: Change default dotnet version 27 | run: | 28 | echo '{"sdk":{"version": "${{ steps.dotnet.outputs.dotnet-version }}"}}' > ./global.json 29 | - name: Initialize workflow variables 30 | id: vars 31 | run: | 32 | VERSION=$(echo "${GITHUB_REF#refs/*/}" | sed s/^v//) 33 | VERSION="$VERSION.0" 34 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 35 | echo "APP_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_OUTPUT 36 | - name: Build 37 | run: | 38 | dotnet restore ${{ env.project }} --no-cache 39 | dotnet publish --nologo --no-restore --configuration=Release --framework=net8.0 ${{ env.project }} 40 | mkdir -p artifacts 41 | cp ./Jellyfin.Plugin.MetaShark/bin/Release/net8.0/Jellyfin.Plugin.MetaShark.dll ./artifacts/ 42 | - name: Upload artifact 43 | uses: actions/upload-artifact@v3 44 | with: 45 | name: ${{steps.vars.outputs.APP_NAME}} 46 | path: artifacts 47 | retention-days: 7 48 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: "🏗️ Build Plugin" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-dotnet@v3 15 | id: dotnet 16 | with: 17 | dotnet-version: 8.0.x 18 | - name: Change default dotnet version 19 | run: | 20 | echo '{"sdk":{"version": "${{ steps.dotnet.outputs.dotnet-version }}"}}' > ./global.json 21 | - name: Install dependencies 22 | run: dotnet restore 23 | - name: Build 24 | run: dotnet build --no-restore 25 | # - name: Test 26 | # run: dotnet test --no-restore --verbosity normal 27 | -------------------------------------------------------------------------------- /.github/workflows/issue_close_inactive.yml: -------------------------------------------------------------------------------- 1 | name: "🚫 Close Inactive" 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 1" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | close-inactive: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: close-issues 13 | uses: actions/stale@v7 14 | with: 15 | stale-issue-message: "This issue was closed due to inactive more than 30 days. You can reopen it if you think it should continue." 16 | exempt-issue-labels: "FAQ,question,bug,enhancement" 17 | days-before-stale: 30 18 | days-before-close: 0 19 | days-before-pr-stale: -1 20 | days-before-pr-close: -1 21 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: "🚀 Publish Plugin" 2 | 3 | on: 4 | push: 5 | tags: ["*"] 6 | 7 | env: 8 | dotnet-version: 8.0.x 9 | python-version: 3.8 10 | project: Jellyfin.Plugin.MetaShark/Jellyfin.Plugin.MetaShark.csproj 11 | artifact: metashark 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | name: Build & Release 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Get tags (For CHANGELOG) 21 | run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* 22 | - name: Setup dotnet 23 | uses: actions/setup-dotnet@v3 24 | id: dotnet 25 | with: 26 | dotnet-version: ${{ env.dotnet-version }} 27 | - name: Change default dotnet version 28 | run: | 29 | echo '{"sdk":{"version": "${{ steps.dotnet.outputs.dotnet-version }}"}}' > ./global.json 30 | - name: Setup python 31 | uses: actions/setup-python@v4 32 | with: 33 | python-version: ${{ env.python-version }} 34 | - name: Initialize workflow variables 35 | id: vars 36 | run: | 37 | VERSION=$(echo "${GITHUB_REF#refs/*/}" | sed s/^v//) 38 | VERSION="$VERSION.0" 39 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 40 | echo "APP_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_OUTPUT 41 | - name: Build 42 | run: | 43 | dotnet restore ${{ env.project }} --no-cache 44 | dotnet publish --nologo --no-restore --configuration=Release --framework=net8.0 -p:Version=${{steps.vars.outputs.VERSION}} ${{ env.project }} 45 | mkdir -p artifacts 46 | zip -j ./artifacts/${{ env.artifact }}_${{steps.vars.outputs.VERSION}}.zip ./Jellyfin.Plugin.MetaShark/bin/Release/net8.0/Jellyfin.Plugin.MetaShark.dll 47 | - name: Generate manifest 48 | run: python3 ./scripts/generate_manifest.py ./artifacts/${{ env.artifact }}_${{steps.vars.outputs.VERSION}}.zip ${GITHUB_REF#refs/*/} 49 | env: 50 | CN_DOMAIN: ${{ vars.CN_DOMAIN }} 51 | - name: Publish release 52 | uses: svenstaro/upload-release-action@v2 53 | with: 54 | repo_token: ${{ secrets.GITHUB_TOKEN }} 55 | file: ./artifacts/${{ env.artifact }}_*.zip 56 | tag: ${{ github.ref }} 57 | release_name: '${{ github.ref_name }}' 58 | file_glob: true 59 | overwrite: true 60 | - name: Publish manifest 61 | uses: svenstaro/upload-release-action@v2 62 | with: 63 | repo_token: ${{ secrets.GITHUB_TOKEN }} 64 | file: ./manifest*.json 65 | tag: "manifest" 66 | overwrite: true 67 | file_glob: true 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | .vs/ 4 | .idea/ 5 | artifacts 6 | **/.DS_Store 7 | metashark/ 8 | *.json 9 | .vscode 10 | -------------------------------------------------------------------------------- /AnitomySharp/Anitomy.cs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014-2017, Eren Okka 3 | * Copyright (c) 2016-2017, Paul Miller 4 | * Copyright (c) 2017-2018, Tyler Bratton 5 | * 6 | * This Source Code Form is subject to the terms of the Mozilla Public 7 | * License, v. 2.0. If a copy of the MPL was not distributed with this 8 | * file, You can obtain one at https://mozilla.org/MPL/2.0/. 9 | */ 10 | 11 | using System; 12 | 13 | using System.Collections.Generic; 14 | using System.Linq; 15 | 16 | namespace AnitomySharp 17 | { 18 | 19 | /// 20 | /// A library capable of parsing Anime filenames. 21 | /// 22 | /// 用于解析动漫文件名的库。 23 | /// 24 | /// This code is a C++ to C# port of Anitomy, 25 | /// using the already existing Java port AnitomyJ as a reference. 26 | /// 27 | public class AnitomySharp 28 | { 29 | /// 30 | /// 31 | /// 32 | private AnitomySharp() { } 33 | 34 | /// 35 | /// Parses an anime into its consituent elements. 36 | /// 37 | /// 将动画文件名拆分为其组成元素。 38 | /// 39 | /// the anime file name 动画文件名 40 | /// the list of parsed elements 分解后的元素列表 41 | public static IEnumerable Parse(string filename) 42 | { 43 | return Parse(filename, new Options()); 44 | } 45 | 46 | /// 47 | /// Parses an anime into its constituent elements. 48 | /// 49 | /// 将动画文件名拆分为其组成元素。 50 | /// 51 | /// the anime file name 动画文件名 52 | /// the options to parse with, use to use default options 53 | /// the list of parsed elements 分解后的元素列表 54 | /// **逻辑:** 55 | /// 1. 提取文件扩展名; 56 | /// 2. 57 | /// 3. #TODO 58 | /// 59 | public static IEnumerable Parse(string filename, Options options) 60 | { 61 | var elements = new List(32); 62 | var tokens = new List(); 63 | 64 | /** remove/parse extension */ 65 | var fname = filename; 66 | if (options.ParseFileExtension) 67 | { 68 | var extension = ""; 69 | if (RemoveExtensionFromFilename(ref fname, ref extension)) 70 | { 71 | /** 将文件扩展名元素加入元素列表 */ 72 | elements.Add(new Element(Element.ElementCategory.ElementFileExtension, extension)); 73 | } 74 | } 75 | 76 | /** set filename */ 77 | if (string.IsNullOrEmpty(filename)) 78 | { 79 | return elements; 80 | } 81 | /** 将去除扩展名后的文件名加入元素列表 */ 82 | elements.Add(new Element(Element.ElementCategory.ElementFileName, fname)); 83 | 84 | /** tokenize 85 | 1. 根据括号、一眼真的关键词、分隔符进行分词(带先后顺序) 86 | 2. 只将一眼真的关键字加入元素列表 87 | */ 88 | var isTokenized = new Tokenizer(fname, elements, options, tokens).Tokenize(); 89 | if (!isTokenized) 90 | { 91 | return elements; 92 | } 93 | new Parser(elements, options, tokens).Parse(); 94 | 95 | // elements.ForEach(x => Console.WriteLine("\"" + x.Category + "\"" + ": " + "\"" + x.Value + "\"")); 96 | 97 | return elements; 98 | } 99 | 100 | 101 | /// 102 | /// Removes the extension from the 103 | /// 104 | /// 确认扩展名有效,即在指定的文件扩展名元素类别中,然后去除文件扩展名 105 | /// 106 | /// the ref that will be updated with the new filename 107 | /// the ref that will be updated with the file extension 108 | /// if the extension was successfully separated from the filename 109 | private static bool RemoveExtensionFromFilename(ref string filename, ref string extension) 110 | { 111 | int position; 112 | if (string.IsNullOrEmpty(filename) || (position = filename.LastIndexOf('.')) == -1) 113 | { 114 | return false; 115 | } 116 | 117 | /** remove file extension */ 118 | extension = filename.Substring(position + 1); 119 | if (extension.Length > 4 || !extension.All(char.IsLetterOrDigit)) 120 | { 121 | return false; 122 | } 123 | 124 | /** check if valid anime extension */ 125 | var keyword = KeywordManager.Normalize(extension); 126 | if (!KeywordManager.Contains(Element.ElementCategory.ElementFileExtension, keyword)) 127 | { 128 | return false; 129 | } 130 | 131 | filename = filename.Substring(0, position); 132 | return true; 133 | } 134 | } 135 | } -------------------------------------------------------------------------------- /AnitomySharp/AnitomySharp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | true 6 | AnitomySharp.NET6 7 | 0.4.0 8 | 0.4.0 9 | tabratton;senritsu;chu-shen 10 | AnitomySharp is a C# port of Anitomy by erengy, a library for parsing anime video filenames. All credit to erengy for the actual library and logic. 11 | This fork of AnitomySharp is inspired by tabratton and senritsu, which adds more custom rules. 12 | 13 | https://github.com/chu-shen/AnitomySharp.git 14 | git 15 | Anitomy Anime 16 | true 17 | LICENSE 18 | README.md 19 | AnitomySharp.xml 20 | false 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /AnitomySharp/Element.cs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014-2017, Eren Okka 3 | * Copyright (c) 2016-2017, Paul Miller 4 | * Copyright (c) 2017-2018, Tyler Bratton 5 | * 6 | * This Source Code Form is subject to the terms of the Mozilla Public 7 | * License, v. 2.0. If a copy of the MPL was not distributed with this 8 | * file, You can obtain one at https://mozilla.org/MPL/2.0/. 9 | */ 10 | 11 | namespace AnitomySharp 12 | { 13 | /// 14 | /// An represents an identified Anime . 15 | /// A single filename may contain multiple of the same 16 | /// token(e.g ). 17 | /// 18 | /// 一个元素即是一个已标识的标记(token) 19 | /// 20 | /// 单个文件名可能包含多个相同的标记,比如:`ElementEpisodeNumber`元素类别的标记 21 | /// 22 | public class Element 23 | { 24 | /// 25 | /// Element Categories 26 | /// 27 | /// 元素类别 28 | /// 29 | public enum ElementCategory 30 | { 31 | /// 32 | /// 元素类别:动画季度,不带前缀 33 | /// 34 | ElementAnimeSeason, 35 | /// 36 | /// 元素类别:季度前缀,用于标识季度的元素类别 37 | /// 38 | ElementAnimeSeasonPrefix, 39 | /// 40 | /// 元素类别:动画名 41 | /// 42 | ElementAnimeTitle, 43 | /// 44 | /// 元素类别:动画类型 45 | /// 46 | ElementAnimeType, 47 | /// 48 | /// 元素类别:动画年份,唯一 49 | /// 50 | ElementAnimeYear, 51 | /// 52 | /// 元素类别:音频术语 53 | /// 54 | ElementAudioTerm, 55 | /// 56 | /// 元素类别:设备,用于标识设备类型 57 | /// 58 | ElementDeviceCompatibility, 59 | /// 60 | /// 元素类别:剧集数 61 | /// 62 | ElementEpisodeNumber, 63 | /// 64 | /// 元素类别:等效剧集数,常见于多季度番剧 65 | /// 66 | ElementEpisodeNumberAlt, 67 | /// 68 | /// 元素类别:剧集前缀,比如:“E” 69 | /// 70 | ElementEpisodePrefix, 71 | /// 72 | /// 元素类别:剧集名 73 | /// 74 | ElementEpisodeTitle, 75 | /// 76 | /// 元素类别:文件校验码,唯一 77 | /// 78 | ElementFileChecksum, 79 | /// 80 | /// 元素类别:文件扩展名,唯一 81 | /// 82 | ElementFileExtension, 83 | /// 84 | /// 文件名,唯一 85 | /// 86 | ElementFileName, 87 | /// 88 | /// 元素类别:语言 89 | /// 90 | ElementLanguage, 91 | /// 92 | /// 元素类别:其他,暂时无法分类的元素 93 | /// 94 | ElementOther, 95 | /// 96 | /// 元素类别:发布组,唯一 97 | /// 98 | ElementReleaseGroup, 99 | /// 100 | /// 元素类别:发布信息 101 | /// 102 | ElementReleaseInformation, 103 | /// 104 | /// 元素类别:发布版本 105 | /// 106 | ElementReleaseVersion, 107 | /// 108 | /// 元素类别:来源 109 | /// 110 | ElementSource, 111 | /// 112 | /// 元素类别:字幕 113 | /// 114 | ElementSubtitles, 115 | /// 116 | /// 元素类别:视频分辨率 117 | /// 118 | ElementVideoResolution, 119 | /// 120 | /// 元素类别:视频术语 121 | /// 122 | ElementVideoTerm, 123 | /// 124 | /// 元素类别:卷数 125 | /// 126 | ElementVolumeNumber, 127 | /// 128 | /// 元素类别:卷前缀 129 | /// 130 | ElementVolumePrefix, 131 | /// 132 | /// 元素类别:未知元素类型 133 | /// 134 | ElementUnknown 135 | } 136 | 137 | /// 138 | /// 139 | /// 140 | public ElementCategory Category { get; set; } 141 | /// 142 | /// 143 | /// 144 | public string Value { get; } 145 | 146 | /// 147 | /// Constructs a new Element 148 | /// 149 | /// 构造一个元素 150 | /// 151 | /// the category of the element 152 | /// the element's value 153 | public Element(ElementCategory category, string value) 154 | { 155 | Category = category; 156 | Value = value; 157 | } 158 | 159 | /// 160 | /// 161 | /// 162 | /// 163 | public override int GetHashCode() 164 | { 165 | return -1926371015 + Value.GetHashCode(); 166 | } 167 | 168 | /// 169 | /// 170 | /// 171 | /// 172 | /// 173 | public override bool Equals(object obj) 174 | { 175 | if (this == obj) 176 | { 177 | return true; 178 | } 179 | 180 | if (obj == null || GetType() != obj.GetType()) 181 | { 182 | return false; 183 | } 184 | 185 | var other = (Element)obj; 186 | return Category.Equals(other.Category); 187 | } 188 | /// 189 | /// 190 | /// 191 | /// 192 | public override string ToString() 193 | { 194 | return $"Element{{category={Category}, value='{Value}'}}"; 195 | } 196 | } 197 | } -------------------------------------------------------------------------------- /AnitomySharp/Options.cs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014-2017, Eren Okka 3 | * Copyright (c) 2016-2017, Paul Miller 4 | * Copyright (c) 2017-2018, Tyler Bratton 5 | * 6 | * This Source Code Form is subject to the terms of the Mozilla Public 7 | * License, v. 2.0. If a copy of the MPL was not distributed with this 8 | * file, You can obtain one at https://mozilla.org/MPL/2.0/. 9 | */ 10 | 11 | namespace AnitomySharp 12 | { 13 | 14 | /// 15 | /// AnitomySharp search configuration options 16 | /// 17 | /// 提取元素时的默认配置项 18 | /// 19 | public class Options 20 | { 21 | /// 22 | /// 提取元素时使用的分隔符 23 | /// 24 | public string AllowedDelimiters { get; } 25 | /// 26 | /// 是否尝试提取集数。`true`表示提取 27 | /// 28 | public bool ParseEpisodeNumber { get; } 29 | /// 30 | /// 是否尝试提取本集标题。`true`表示提取 31 | /// 32 | public bool ParseEpisodeTitle { get; } 33 | /// 34 | /// 是否提取文件扩展名。`true`表示提取 35 | /// 36 | public bool ParseFileExtension { get; } 37 | /// 38 | /// 是否提取发布组。`true`表示提取 39 | /// 40 | public bool ParseReleaseGroup { get; } 41 | /// 42 | /// 提取元素时的配置项 43 | /// 44 | /// 默认值:" _.+,|" 45 | /// 默认值:true 46 | /// 默认值:true 47 | /// 默认值:true 48 | /// 默认值:true 49 | public Options(string delimiters = " _.+,| ", bool episode = true, bool title = true, bool extension = true, bool group = true) 50 | { 51 | AllowedDelimiters = delimiters; 52 | ParseEpisodeNumber = episode; 53 | ParseEpisodeTitle = title; 54 | ParseFileExtension = extension; 55 | ParseReleaseGroup = group; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /AnitomySharp/StringHelper.cs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014-2017, Eren Okka 3 | * Copyright (c) 2016-2017, Paul Miller 4 | * Copyright (c) 2017-2018, Tyler Bratton 5 | * 6 | * This Source Code Form is subject to the terms of the Mozilla Public 7 | * License, v. 2.0. If a copy of the MPL was not distributed with this 8 | * file, You can obtain one at https://mozilla.org/MPL/2.0/. 9 | */ 10 | 11 | using System; 12 | using System.Linq; 13 | 14 | namespace AnitomySharp 15 | { 16 | 17 | /// 18 | /// A string helper class that is analogous to string.cpp of the original Anitomy, and StringHelper.java of AnitomyJ. 19 | /// 20 | public static class StringHelper 21 | { 22 | 23 | /// 24 | /// Returns whether or not the character is alphanumeric 25 | /// 26 | /// 如果给定字符为字母或数字,则返回`true` 27 | /// 28 | /// 29 | /// 30 | public static bool IsAlphanumericChar(char c) 31 | { 32 | return c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z'; 33 | } 34 | 35 | /// 36 | /// Returns whether or not the character is a hex character. 37 | /// 38 | /// 如果给定字符为十六进制字符,则返回`true` 39 | /// 40 | /// 41 | /// 42 | private static bool IsHexadecimalChar(char c) 43 | { 44 | return c >= '0' && c <= '9' || c >= 'A' && c <= 'F' || c >= 'a' && c <= 'f'; 45 | } 46 | 47 | /// 48 | /// Returns whether or not the character is a latin character 49 | /// 50 | /// 判断给定字符是否为拉丁字符 51 | /// 52 | /// 53 | /// 54 | private static bool IsLatinChar(char c) 55 | { 56 | // We're just checking until the end of the Latin Extended-B block, 57 | // rather than all the blocks that belong to the Latin script. 58 | return c <= '\u024F'; 59 | } 60 | 61 | /// 62 | /// Returns whether or not the character is a Chinese character 63 | /// 64 | /// 判断给定字符是否为中文字符 65 | /// 66 | /// 67 | /// 68 | private static bool IsChineseChar(char c) 69 | { 70 | // We're just checking until the end of the Latin Extended-B block, 71 | // rather than all the blocks that belong to the Latin script. 72 | return c <= '\u9FFF' && c >= '\u4E00'; 73 | } 74 | 75 | /// 76 | /// Returns whether or not the str is a hex string. 77 | /// 78 | /// 如果给定字符串为十六进制字符串,则返回`true` 79 | /// 80 | /// 81 | /// 82 | public static bool IsHexadecimalString(string str) 83 | { 84 | return !string.IsNullOrEmpty(str) && str.All(IsHexadecimalChar); 85 | } 86 | 87 | /// 88 | /// Returns whether or not the str is mostly a latin string. 89 | /// 90 | /// 判断给定字符串是否过半字符为拉丁 91 | /// 92 | /// 93 | /// 94 | public static bool IsMostlyLatinString(string str) 95 | { 96 | var length = !string.IsNullOrEmpty(str) ? 1.0 : str.Length; 97 | return str.Where(IsLatinChar).Count() / length >= 0.5; 98 | } 99 | 100 | /// 101 | /// Returns whether or not the str is mostly a Chinese string. 102 | /// 103 | /// 判断给定字符串是否过半字符为中文 104 | /// 105 | /// 106 | /// 107 | public static bool IsMostlyChineseString(string str) 108 | { 109 | var length = !string.IsNullOrEmpty(str) ? 1.0 : str.Length; 110 | return str.Where(IsChineseChar).Count() / length >= 0.5; 111 | } 112 | /// 113 | /// Returns whether or not the str is a numeric string. 114 | /// 115 | /// 判断字符串是否全数字 116 | /// 117 | /// 118 | /// 119 | public static bool IsNumericString(string str) 120 | { 121 | return str.All(char.IsDigit); 122 | } 123 | /// 124 | /// Returns whether or not the str is a alpha string. 125 | /// 126 | /// 判断字符串是否全字母 127 | /// 128 | /// 129 | /// 130 | public static bool IsAlphaString(string str) 131 | { 132 | return str.All(char.IsLetter); 133 | } 134 | 135 | /// 136 | /// Returns the int value of the str; 0 otherwise. 137 | /// 138 | /// 139 | /// 140 | public static int StringToInt(string str) 141 | { 142 | try 143 | { 144 | return int.Parse(str); 145 | } 146 | catch (Exception ex) 147 | { 148 | Console.WriteLine(ex); 149 | return 0; 150 | } 151 | } 152 | 153 | /// 154 | /// 提取给定范围的子字符串 155 | /// 156 | /// 157 | /// 158 | /// 159 | /// 160 | public static string SubstringWithCheck(string str, int start, int count) 161 | { 162 | if (start + count > str.Length) count = str.Length - start; 163 | return str.Substring(start, count); 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /AnitomySharp/TokenRange.cs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014-2017, Eren Okka 3 | * Copyright (c) 2016-2017, Paul Miller 4 | * Copyright (c) 2017-2018, Tyler Bratton 5 | * 6 | * This Source Code Form is subject to the terms of the Mozilla Public 7 | * License, v. 2.0. If a copy of the MPL was not distributed with this 8 | * file, You can obtain one at https://mozilla.org/MPL/2.0/. 9 | */ 10 | 11 | namespace AnitomySharp 12 | { 13 | /// 14 | /// 标记(token)的位置 15 | /// 16 | public struct TokenRange 17 | { 18 | /// 19 | /// 偏移值 20 | /// 21 | public int Offset; 22 | /// 23 | /// Token长度 24 | /// 25 | public int Size; 26 | 27 | /// 28 | /// 构造 29 | /// 30 | /// 31 | /// 32 | public TokenRange(int offset, int size) 33 | { 34 | Offset = offset; 35 | Size = size; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/BoxSetProviderTest.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Jellyfin.Plugin.MetaShark.Providers; 4 | using MediaBrowser.Controller.Library; 5 | using MediaBrowser.Controller.Providers; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.Extensions.Logging; 8 | using Moq; 9 | 10 | namespace Jellyfin.Plugin.MetaShark.Test 11 | { 12 | [TestClass] 13 | public class BoxSetProviderTest 14 | { 15 | 16 | ILoggerFactory loggerFactory = LoggerFactory.Create(builder => 17 | builder.AddSimpleConsole(options => 18 | { 19 | options.IncludeScopes = true; 20 | options.SingleLine = true; 21 | options.TimestampFormat = "hh:mm:ss "; 22 | })); 23 | 24 | 25 | [TestMethod] 26 | public void TestGetMetadata() 27 | { 28 | var info = new BoxSetInfo() { Name = "攻壳机动队(系列)", MetadataLanguage = "zh" }; 29 | var httpClientFactory = new DefaultHttpClientFactory(); 30 | var libraryManagerStub = new Mock(); 31 | libraryManagerStub.Setup(x => x.ParseName(info.Name)).Returns(new ItemLookupInfo(){Name = info.Name}); 32 | var httpContextAccessorStub = new Mock(); 33 | var doubanApi = new DoubanApi(loggerFactory); 34 | var tmdbApi = new TmdbApi(loggerFactory); 35 | var omdbApi = new OmdbApi(loggerFactory); 36 | var imdbApi = new ImdbApi(loggerFactory); 37 | 38 | Task.Run(async () => 39 | { 40 | var provider = new BoxSetProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 41 | var result = await provider.GetMetadata(info, CancellationToken.None); 42 | Assert.IsNotNull(result); 43 | 44 | var str = result.ToJson(); 45 | Console.WriteLine(result.ToJson()); 46 | }).GetAwaiter().GetResult(); 47 | } 48 | 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/DefaultHttpClientFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace Jellyfin.Plugin.MetaShark.Test 8 | { 9 | internal class DefaultHttpClientFactory : IHttpClientFactory 10 | { 11 | public HttpClient CreateClient(string name) 12 | { 13 | return new HttpClient(); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/DoubanApiTest.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace Jellyfin.Plugin.MetaShark.Test 6 | { 7 | [TestClass] 8 | public class DoubanApiTest 9 | { 10 | private TestContext testContextInstance; 11 | 12 | /// 13 | /// Gets or sets the test context which provides 14 | /// information about and functionality for the current test run. 15 | /// 16 | public TestContext TestContext 17 | { 18 | get { return testContextInstance; } 19 | set { testContextInstance = value; } 20 | } 21 | 22 | ILoggerFactory loggerFactory = LoggerFactory.Create(builder => 23 | builder.AddSimpleConsole(options => 24 | { 25 | options.IncludeScopes = true; 26 | options.SingleLine = true; 27 | options.TimestampFormat = "hh:mm:ss "; 28 | })); 29 | 30 | 31 | 32 | [TestMethod] 33 | public void TestSearch() 34 | { 35 | var keyword = "声生不息"; 36 | var api = new DoubanApi(loggerFactory); 37 | 38 | Task.Run(async () => 39 | { 40 | try 41 | { 42 | var result = await api.SearchAsync(keyword, CancellationToken.None); 43 | TestContext.WriteLine(result.ToJson()); 44 | } 45 | catch (Exception ex) 46 | { 47 | TestContext.WriteLine(ex.Message); 48 | } 49 | }).GetAwaiter().GetResult(); 50 | } 51 | 52 | 53 | [TestMethod] 54 | public void TestSearchBySuggest() 55 | { 56 | var keyword = "重返少年时"; 57 | var api = new DoubanApi(loggerFactory); 58 | 59 | Task.Run(async () => 60 | { 61 | try 62 | { 63 | var result = await api.SearchBySuggestAsync(keyword, CancellationToken.None); 64 | var str = result.ToJson(); 65 | TestContext.WriteLine(result.ToJson()); 66 | } 67 | catch (Exception ex) 68 | { 69 | TestContext.WriteLine(ex.Message); 70 | } 71 | }).GetAwaiter().GetResult(); 72 | } 73 | 74 | 75 | [TestMethod] 76 | public void TestGetVideoBySidAsync() 77 | { 78 | var sid = "26654184"; 79 | 80 | var api = new DoubanApi(loggerFactory); 81 | 82 | Task.Run(async () => 83 | { 84 | try 85 | { 86 | var result = await api.GetMovieAsync(sid, CancellationToken.None); 87 | TestContext.WriteLine(result.ToJson()); 88 | } 89 | catch (Exception ex) 90 | { 91 | Console.WriteLine(ex.Message); 92 | } 93 | }).GetAwaiter().GetResult(); 94 | } 95 | 96 | [TestMethod] 97 | public void TestFixGetImage() 98 | { 99 | // 演员入驻了豆瓣, 下载的不是演员的头像#5 100 | var sid = "35460157"; 101 | 102 | var api = new DoubanApi(loggerFactory); 103 | 104 | Task.Run(async () => 105 | { 106 | try 107 | { 108 | var result = await api.GetMovieAsync(sid, CancellationToken.None); 109 | Assert.AreEqual("https://img2.doubanio.com/view/celebrity/raw/public/p1598199472.61.jpg", result.Celebrities.First(x => x.Name == "刘陆").Img); 110 | } 111 | catch (Exception ex) 112 | { 113 | Console.WriteLine(ex.Message); 114 | } 115 | }).GetAwaiter().GetResult(); 116 | } 117 | 118 | [TestMethod] 119 | public void TestGetCelebritiesBySidAsync() 120 | { 121 | var sid = "26654184"; 122 | 123 | var api = new DoubanApi(loggerFactory); 124 | 125 | Task.Run(async () => 126 | { 127 | try 128 | { 129 | var result = await api.GetCelebritiesBySidAsync(sid, CancellationToken.None); 130 | TestContext.WriteLine(result.ToJson()); 131 | } 132 | catch (Exception ex) 133 | { 134 | Console.WriteLine(ex.Message); 135 | } 136 | }).GetAwaiter().GetResult(); 137 | } 138 | 139 | [TestMethod] 140 | public void TestGetCelebritiesByCidAsync() 141 | { 142 | var cid = "1340364"; 143 | 144 | var api = new DoubanApi(loggerFactory); 145 | 146 | Task.Run(async () => 147 | { 148 | try 149 | { 150 | var result = await api.GetCelebrityAsync(cid, CancellationToken.None); 151 | TestContext.WriteLine(result.ToJson()); 152 | } 153 | catch (Exception ex) 154 | { 155 | Console.WriteLine(ex.Message); 156 | } 157 | }).GetAwaiter().GetResult(); 158 | } 159 | 160 | [TestMethod] 161 | public void TestGetCelebrityPhotosAsync() 162 | { 163 | var cid = "1322205"; 164 | 165 | var api = new DoubanApi(loggerFactory); 166 | 167 | Task.Run(async () => 168 | { 169 | try 170 | { 171 | var result = await api.GetCelebrityPhotosAsync(cid, CancellationToken.None); 172 | TestContext.WriteLine(result.ToJson()); 173 | } 174 | catch (Exception ex) 175 | { 176 | Console.WriteLine(ex.Message); 177 | } 178 | }).GetAwaiter().GetResult(); 179 | } 180 | 181 | 182 | 183 | [TestMethod] 184 | public void TestParseCelebrityName() 185 | { 186 | 187 | var api = new DoubanApi(loggerFactory); 188 | 189 | 190 | var name = "佩吉·陆 Peggy Lu"; 191 | var result = api.ParseCelebrityName(name); 192 | Assert.AreEqual(result, "佩吉·陆"); 193 | 194 | name = "Antony Coleman Antony Coleman"; 195 | result = api.ParseCelebrityName(name); 196 | Assert.AreEqual(result, "Antony Coleman"); 197 | 198 | name = "Dick Cook"; 199 | result = api.ParseCelebrityName(name); 200 | Assert.AreEqual(result, "Dick Cook"); 201 | 202 | name = "李凡秀"; 203 | result = api.ParseCelebrityName(name); 204 | Assert.AreEqual(result, "李凡秀"); 205 | 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/EpisodeProviderTest.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Jellyfin.Plugin.MetaShark.Providers; 4 | using MediaBrowser.Controller.Library; 5 | using MediaBrowser.Controller.Providers; 6 | using Microsoft.Extensions.Logging; 7 | using Moq; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Text; 12 | using System.Threading.Tasks; 13 | using MediaBrowser.Common.Configuration; 14 | using MediaBrowser.Model.Serialization; 15 | using Microsoft.AspNetCore.Http; 16 | using MediaBrowser.Model.Entities; 17 | 18 | namespace Jellyfin.Plugin.MetaShark.Test 19 | { 20 | [TestClass] 21 | public class EpisodeProviderTest 22 | { 23 | 24 | ILoggerFactory loggerFactory = LoggerFactory.Create(builder => 25 | builder.AddSimpleConsole(options => 26 | { 27 | options.IncludeScopes = true; 28 | options.SingleLine = true; 29 | options.TimestampFormat = "hh:mm:ss "; 30 | })); 31 | 32 | 33 | 34 | [TestMethod] 35 | public void TestGetMetadata() 36 | { 37 | var doubanApi = new DoubanApi(loggerFactory); 38 | var tmdbApi = new TmdbApi(loggerFactory); 39 | var omdbApi = new OmdbApi(loggerFactory); 40 | var imdbApi = new ImdbApi(loggerFactory); 41 | 42 | var httpClientFactory = new DefaultHttpClientFactory(); 43 | var libraryManagerStub = new Mock(); 44 | var httpContextAccessorStub = new Mock(); 45 | 46 | Task.Run(async () => 47 | { 48 | var info = new EpisodeInfo() 49 | { 50 | Name = "Spice and Wolf", 51 | Path = "/test/Spice and Wolf/S00/[VCB-Studio] Spice and Wolf II [01][Hi444pp_1080p][x264_flac].mkv", 52 | MetadataLanguage = "zh", 53 | ParentIndexNumber = 0, 54 | SeriesProviderIds = new Dictionary() { { MetadataProvider.Tmdb.ToString(), "26707" } }, 55 | IsAutomated = false, 56 | }; 57 | var provider = new EpisodeProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 58 | var result = await provider.GetMetadata(info, CancellationToken.None); 59 | Assert.IsNotNull(result); 60 | 61 | var str = result.ToJson(); 62 | Console.WriteLine(result.ToJson()); 63 | }).GetAwaiter().GetResult(); 64 | } 65 | 66 | [TestMethod] 67 | public void TestFixParseInfo() 68 | { 69 | var doubanApi = new DoubanApi(loggerFactory); 70 | var tmdbApi = new TmdbApi(loggerFactory); 71 | var omdbApi = new OmdbApi(loggerFactory); 72 | var imdbApi = new ImdbApi(loggerFactory); 73 | 74 | var httpClientFactory = new DefaultHttpClientFactory(); 75 | var libraryManagerStub = new Mock(); 76 | var httpContextAccessorStub = new Mock(); 77 | 78 | 79 | var provider = new EpisodeProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 80 | var parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/[POPGO][Stand_Alone_Complex][05][1080P][BluRay][x264_FLACx2_AC3x1][chs_jpn][D87C36B6].mkv" }); 81 | Assert.AreEqual(parseResult.IndexNumber, 5); 82 | 83 | parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/Fullmetal Alchemist Brotherhood.E05.1920X1080" }); 84 | Assert.AreEqual(parseResult.IndexNumber, 5); 85 | 86 | parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/[SAIO-Raws] Neon Genesis Evangelion 05 [BD 1440x1080 HEVC-10bit OPUSx2 ASSx2].mkv" }); 87 | Assert.AreEqual(parseResult.IndexNumber, 5); 88 | 89 | parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/[Moozzi2] Samurai Champloo [SP03] Battlecry (Opening) PV (BD 1920x1080 x.264 AC3).mkv" }); 90 | Assert.AreEqual(parseResult.IndexNumber, 3); 91 | Assert.AreEqual(parseResult.ParentIndexNumber, 0); 92 | } 93 | 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/ImdbApiTest.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Microsoft.Extensions.Logging; 3 | 4 | namespace Jellyfin.Plugin.MetaShark.Test 5 | { 6 | [TestClass] 7 | public class ImdbApiTest 8 | { 9 | private TestContext testContextInstance; 10 | 11 | /// 12 | /// Gets or sets the test context which provides 13 | /// information about and functionality for the current test run. 14 | /// 15 | public TestContext TestContext 16 | { 17 | get { return testContextInstance; } 18 | set { testContextInstance = value; } 19 | } 20 | 21 | ILoggerFactory loggerFactory = LoggerFactory.Create(builder => 22 | builder.AddSimpleConsole(options => 23 | { 24 | options.IncludeScopes = true; 25 | options.SingleLine = true; 26 | options.TimestampFormat = "hh:mm:ss "; 27 | })); 28 | 29 | 30 | 31 | [TestMethod] 32 | public void TestCheckPersonNewImdbID() 33 | { 34 | var api = new ImdbApi(loggerFactory); 35 | 36 | Task.Run(async () => 37 | { 38 | try 39 | { 40 | var id = "nm1123737"; 41 | var result = await api.CheckPersonNewIDAsync(id, CancellationToken.None); 42 | Assert.AreEqual("nm0170924", result); 43 | 44 | id = "nm0170924"; 45 | result = await api.CheckPersonNewIDAsync(id, CancellationToken.None); 46 | Assert.AreEqual(null, result); 47 | } 48 | catch (Exception ex) 49 | { 50 | TestContext.WriteLine(ex.Message); 51 | } 52 | }).GetAwaiter().GetResult(); 53 | } 54 | 55 | 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/Jellyfin.Plugin.MetaShark.Test.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net8.0 4 | enable 5 | enable 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/MovieImageProviderTest.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Jellyfin.Plugin.MetaShark.Model; 4 | using Jellyfin.Plugin.MetaShark.Providers; 5 | using MediaBrowser.Controller.Library; 6 | using MediaBrowser.Controller.Providers; 7 | using MediaBrowser.Model.Entities; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.Extensions.Logging; 10 | using Moq; 11 | using System; 12 | using System.Collections.Generic; 13 | using System.Linq; 14 | using System.Text; 15 | using System.Threading.Tasks; 16 | 17 | namespace Jellyfin.Plugin.MetaShark.Test 18 | { 19 | [TestClass] 20 | public class MovieImageProviderTest 21 | { 22 | 23 | ILoggerFactory loggerFactory = LoggerFactory.Create(builder => 24 | builder.AddSimpleConsole(options => 25 | { 26 | options.IncludeScopes = true; 27 | options.SingleLine = true; 28 | options.TimestampFormat = "hh:mm:ss "; 29 | })); 30 | 31 | 32 | [TestMethod] 33 | public void TestGetImages() 34 | { 35 | var info = new MediaBrowser.Controller.Entities.Movies.Movie() 36 | { 37 | Name = "秒速5厘米", 38 | PreferredMetadataLanguage = "zh", 39 | ProviderIds = new Dictionary { { BaseProvider.DoubanProviderId, "2043546" }, { MetadataProvider.Tmdb.ToString(), "38142" } } 40 | }; 41 | var httpClientFactory = new DefaultHttpClientFactory(); 42 | var libraryManagerStub = new Mock(); 43 | var httpContextAccessorStub = new Mock(); 44 | var doubanApi = new DoubanApi(loggerFactory); 45 | var tmdbApi = new TmdbApi(loggerFactory); 46 | var omdbApi = new OmdbApi(loggerFactory); 47 | var imdbApi = new ImdbApi(loggerFactory); 48 | 49 | Task.Run(async () => 50 | { 51 | var provider = new MovieImageProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 52 | var result = await provider.GetImages(info, CancellationToken.None); 53 | Assert.IsNotNull(result); 54 | 55 | var str = result.ToJson(); 56 | Console.WriteLine(result.ToJson()); 57 | }).GetAwaiter().GetResult(); 58 | } 59 | 60 | [TestMethod] 61 | public void TestGetImagesFromTMDB() 62 | { 63 | var info = new MediaBrowser.Controller.Entities.Movies.Movie() 64 | { 65 | PreferredMetadataLanguage = "zh", 66 | ProviderIds = new Dictionary { { MetadataProvider.Tmdb.ToString(), "752" }, { Plugin.ProviderId, MetaSource.Tmdb.ToString() } } 67 | }; 68 | var httpClientFactory = new DefaultHttpClientFactory(); 69 | var libraryManagerStub = new Mock(); 70 | var httpContextAccessorStub = new Mock(); 71 | var doubanApi = new DoubanApi(loggerFactory); 72 | var tmdbApi = new TmdbApi(loggerFactory); 73 | var omdbApi = new OmdbApi(loggerFactory); 74 | var imdbApi = new ImdbApi(loggerFactory); 75 | 76 | Task.Run(async () => 77 | { 78 | var provider = new MovieImageProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 79 | var result = await provider.GetImages(info, CancellationToken.None); 80 | Assert.IsNotNull(result); 81 | 82 | var str = result.ToJson(); 83 | Console.WriteLine(result.ToJson()); 84 | }).GetAwaiter().GetResult(); 85 | } 86 | 87 | [TestMethod] 88 | public void TestGetImageResponse() 89 | { 90 | var httpClientFactory = new DefaultHttpClientFactory(); 91 | var libraryManagerStub = new Mock(); 92 | var httpContextAccessorStub = new Mock(); 93 | var doubanApi = new DoubanApi(loggerFactory); 94 | var tmdbApi = new TmdbApi(loggerFactory); 95 | var omdbApi = new OmdbApi(loggerFactory); 96 | var imdbApi = new ImdbApi(loggerFactory); 97 | 98 | Task.Run(async () => 99 | { 100 | var provider = new MovieImageProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 101 | var result = await provider.GetImageResponse("https://img1.doubanio.com/view/photo/m/public/p2893270877.jpg", CancellationToken.None); 102 | Assert.IsNotNull(result); 103 | 104 | var str = result.ToJson(); 105 | Console.WriteLine(result.ToJson()); 106 | }).GetAwaiter().GetResult(); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/MovieProviderTest.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Jellyfin.Plugin.MetaShark.Model; 4 | using Jellyfin.Plugin.MetaShark.Providers; 5 | using MediaBrowser.Controller.Library; 6 | using MediaBrowser.Controller.Providers; 7 | using MediaBrowser.Model.Entities; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.Extensions.Logging; 10 | using Moq; 11 | using System; 12 | using System.Collections.Generic; 13 | using System.Linq; 14 | using System.Text; 15 | using System.Threading.Tasks; 16 | 17 | namespace Jellyfin.Plugin.MetaShark.Test 18 | { 19 | [TestClass] 20 | public class MovieProviderTest 21 | { 22 | 23 | ILoggerFactory loggerFactory = LoggerFactory.Create(builder => 24 | builder.AddSimpleConsole(options => 25 | { 26 | options.IncludeScopes = true; 27 | options.SingleLine = true; 28 | options.TimestampFormat = "hh:mm:ss "; 29 | })); 30 | 31 | 32 | [TestMethod] 33 | public void TestSearch() 34 | { 35 | var httpClientFactory = new DefaultHttpClientFactory(); 36 | var libraryManagerStub = new Mock(); 37 | var httpContextAccessorStub = new Mock(); 38 | var doubanApi = new DoubanApi(loggerFactory); 39 | var tmdbApi = new TmdbApi(loggerFactory); 40 | var omdbApi = new OmdbApi(loggerFactory); 41 | var imdbApi = new ImdbApi(loggerFactory); 42 | 43 | Task.Run(async () => 44 | { 45 | var info = new MovieInfo() { Name = "我", MetadataLanguage = "zh" }; 46 | var provider = new MovieProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 47 | var result = await provider.GetSearchResults(info, CancellationToken.None); 48 | Assert.IsNotNull(result); 49 | 50 | var str = result.ToJson(); 51 | Console.WriteLine(result.ToJson()); 52 | }).GetAwaiter().GetResult(); 53 | } 54 | 55 | [TestMethod] 56 | public void TestGetMetadata() 57 | { 58 | var httpClientFactory = new DefaultHttpClientFactory(); 59 | var libraryManagerStub = new Mock(); 60 | var httpContextAccessorStub = new Mock(); 61 | var doubanApi = new DoubanApi(loggerFactory); 62 | var tmdbApi = new TmdbApi(loggerFactory); 63 | var omdbApi = new OmdbApi(loggerFactory); 64 | var imdbApi = new ImdbApi(loggerFactory); 65 | 66 | Task.Run(async () => 67 | { 68 | var info = new MovieInfo() { Name = "姥姥的外孙", MetadataLanguage = "zh" }; 69 | var provider = new MovieProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 70 | var result = await provider.GetMetadata(info, CancellationToken.None); 71 | Assert.IsNotNull(result); 72 | 73 | var str = result.ToJson(); 74 | Console.WriteLine(result.ToJson()); 75 | }).GetAwaiter().GetResult(); 76 | } 77 | 78 | [TestMethod] 79 | public void TestGetMetadataAnime() 80 | { 81 | var info = new MovieInfo() { Name = "[SAIO-Raws] もののけ姫 Mononoke Hime [BD 1920x1036 HEVC-10bit OPUSx2 AC3]" }; 82 | var httpClientFactory = new DefaultHttpClientFactory(); 83 | var libraryManagerStub = new Mock(); 84 | var httpContextAccessorStub = new Mock(); 85 | var doubanApi = new DoubanApi(loggerFactory); 86 | var tmdbApi = new TmdbApi(loggerFactory); 87 | var omdbApi = new OmdbApi(loggerFactory); 88 | var imdbApi = new ImdbApi(loggerFactory); 89 | 90 | Task.Run(async () => 91 | { 92 | var provider = new MovieProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 93 | var result = await provider.GetMetadata(info, CancellationToken.None); 94 | Assert.IsNotNull(result); 95 | 96 | var str = result.ToJson(); 97 | Console.WriteLine(result.ToJson()); 98 | }).GetAwaiter().GetResult(); 99 | } 100 | 101 | [TestMethod] 102 | public void TestGetMetadataByTMDB() 103 | { 104 | var info = new MovieInfo() { Name = "人生大事", MetadataLanguage = "zh", ProviderIds = new Dictionary { { Plugin.ProviderId, MetaSource.Tmdb.ToString() }, { MetadataProvider.Tmdb.ToString(), "945664" } } }; 105 | var httpClientFactory = new DefaultHttpClientFactory(); 106 | var libraryManagerStub = new Mock(); 107 | var httpContextAccessorStub = new Mock(); 108 | var doubanApi = new DoubanApi(loggerFactory); 109 | var tmdbApi = new TmdbApi(loggerFactory); 110 | var omdbApi = new OmdbApi(loggerFactory); 111 | var imdbApi = new ImdbApi(loggerFactory); 112 | 113 | Task.Run(async () => 114 | { 115 | var provider = new MovieProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 116 | var result = await provider.GetMetadata(info, CancellationToken.None); 117 | Assert.IsNotNull(result); 118 | 119 | var str = result.ToJson(); 120 | Console.WriteLine(result.ToJson()); 121 | }).GetAwaiter().GetResult(); 122 | } 123 | 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/PersonProviderTest.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Jellyfin.Plugin.MetaShark.Providers; 4 | using MediaBrowser.Controller.Library; 5 | using MediaBrowser.Controller.Providers; 6 | using MediaBrowser.Model.Entities; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.Extensions.Logging; 9 | using Moq; 10 | using System; 11 | using System.Collections.Generic; 12 | using System.Linq; 13 | using System.Text; 14 | using System.Threading.Tasks; 15 | 16 | namespace Jellyfin.Plugin.MetaShark.Test 17 | { 18 | [TestClass] 19 | public class PersonProviderTest 20 | { 21 | 22 | ILoggerFactory loggerFactory = LoggerFactory.Create(builder => 23 | builder.AddSimpleConsole(options => 24 | { 25 | options.IncludeScopes = true; 26 | options.SingleLine = true; 27 | options.TimestampFormat = "hh:mm:ss "; 28 | })); 29 | 30 | 31 | [TestMethod] 32 | public void TestGetMetadata() 33 | { 34 | var httpClientFactory = new DefaultHttpClientFactory(); 35 | var libraryManagerStub = new Mock(); 36 | var httpContextAccessorStub = new Mock(); 37 | var doubanApi = new DoubanApi(loggerFactory); 38 | var tmdbApi = new TmdbApi(loggerFactory); 39 | var omdbApi = new OmdbApi(loggerFactory); 40 | var imdbApi = new ImdbApi(loggerFactory); 41 | 42 | Task.Run(async () => 43 | { 44 | var info = new PersonLookupInfo() { ProviderIds = new Dictionary() { { BaseProvider.DoubanProviderId, "27257290" } } }; 45 | var provider = new PersonProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 46 | var result = await provider.GetMetadata(info, CancellationToken.None); 47 | Assert.IsNotNull(result); 48 | 49 | var str = result.ToJson(); 50 | Console.WriteLine(result.ToJson()); 51 | }).GetAwaiter().GetResult(); 52 | } 53 | 54 | [TestMethod] 55 | public void TestGetMetadataByTmdb() 56 | { 57 | var httpClientFactory = new DefaultHttpClientFactory(); 58 | var libraryManagerStub = new Mock(); 59 | var httpContextAccessorStub = new Mock(); 60 | var doubanApi = new DoubanApi(loggerFactory); 61 | var tmdbApi = new TmdbApi(loggerFactory); 62 | var omdbApi = new OmdbApi(loggerFactory); 63 | var imdbApi = new ImdbApi(loggerFactory); 64 | 65 | Task.Run(async () => 66 | { 67 | var info = new PersonLookupInfo() { ProviderIds = new Dictionary() { { MetadataProvider.Tmdb.ToString(), "78871" } } }; 68 | var provider = new PersonProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 69 | var result = await provider.GetMetadata(info, CancellationToken.None); 70 | Assert.IsNotNull(result); 71 | 72 | var str = result.ToJson(); 73 | Console.WriteLine(result.ToJson()); 74 | }).GetAwaiter().GetResult(); 75 | } 76 | 77 | 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/SeasonProviderTest.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Jellyfin.Plugin.MetaShark.Providers; 4 | using MediaBrowser.Controller.Library; 5 | using MediaBrowser.Controller.Providers; 6 | using MediaBrowser.Model.Entities; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.Extensions.Logging; 9 | using Moq; 10 | using System; 11 | using System.Collections.Generic; 12 | using System.Linq; 13 | using System.Text; 14 | using System.Threading.Tasks; 15 | 16 | namespace Jellyfin.Plugin.MetaShark.Test 17 | { 18 | [TestClass] 19 | public class SeasonProviderTest 20 | { 21 | 22 | ILoggerFactory loggerFactory = LoggerFactory.Create(builder => 23 | builder.AddSimpleConsole(options => 24 | { 25 | options.IncludeScopes = true; 26 | options.SingleLine = true; 27 | options.TimestampFormat = "hh:mm:ss "; 28 | })); 29 | 30 | 31 | [TestMethod] 32 | public void TestGetMetadata() 33 | { 34 | var info = new SeasonInfo() { Name = "第 18 季", IndexNumber = 18, SeriesProviderIds = new Dictionary() { { BaseProvider.DoubanProviderId, "2059529" }, { MetadataProvider.Tmdb.ToString(), "34860" } } }; 35 | var httpClientFactory = new DefaultHttpClientFactory(); 36 | var libraryManagerStub = new Mock(); 37 | var httpContextAccessorStub = new Mock(); 38 | var doubanApi = new DoubanApi(loggerFactory); 39 | var tmdbApi = new TmdbApi(loggerFactory); 40 | var omdbApi = new OmdbApi(loggerFactory); 41 | var imdbApi = new ImdbApi(loggerFactory); 42 | 43 | Task.Run(async () => 44 | { 45 | var provider = new SeasonProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 46 | var result = await provider.GetMetadata(info, CancellationToken.None); 47 | Assert.IsNotNull(result); 48 | 49 | var str = result.ToJson(); 50 | Console.WriteLine(result.ToJson()); 51 | }).GetAwaiter().GetResult(); 52 | } 53 | 54 | [TestMethod] 55 | public void TestGuessSeasonNumberByFileName() 56 | { 57 | var info = new SeasonInfo() { }; 58 | var httpClientFactory = new DefaultHttpClientFactory(); 59 | var libraryManagerStub = new Mock(); 60 | var httpContextAccessorStub = new Mock(); 61 | var doubanApi = new DoubanApi(loggerFactory); 62 | var tmdbApi = new TmdbApi(loggerFactory); 63 | var omdbApi = new OmdbApi(loggerFactory); 64 | var imdbApi = new ImdbApi(loggerFactory); 65 | 66 | var provider = new SeasonProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 67 | 68 | var result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/冰与火之歌S01-S08.Game.of.Thrones.1080p.Blu-ray.x265.10bit.AC3/冰与火之歌S2.列王的纷争.2012.1080p.Blu-ray.x265.10bit.AC3"); 69 | Assert.AreEqual(result, 2); 70 | 71 | result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/向往的生活/第2季"); 72 | Assert.AreEqual(result, 2); 73 | 74 | result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/向往的生活 第2季"); 75 | Assert.AreEqual(result, 2); 76 | 77 | result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/向往的生活/第三季"); 78 | Assert.AreEqual(result, 3); 79 | 80 | result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/攻壳机动队Ghost_in_The_Shell_S.A.C._2nd_GIG"); 81 | Assert.AreEqual(result, 2); 82 | 83 | // result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/Spice and Wolf/Spice and Wolf 2"); 84 | // Assert.AreEqual(result, 2); 85 | 86 | result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/Spice and Wolf/Spice and Wolf 2 test"); 87 | Assert.AreEqual(result, null); 88 | 89 | result = provider.GuessSeasonNumberByDirectoryName("/data/downloads/jellyfin/tv/[BDrip] Made in Abyss S02 [7鲁ACG x Sakurato]"); 90 | Assert.AreEqual(result, 2); 91 | } 92 | 93 | [TestMethod] 94 | public void TestGuestDoubanSeasonByYearAsync() 95 | { 96 | var httpClientFactory = new DefaultHttpClientFactory(); 97 | var libraryManagerStub = new Mock(); 98 | var httpContextAccessorStub = new Mock(); 99 | var doubanApi = new DoubanApi(loggerFactory); 100 | var tmdbApi = new TmdbApi(loggerFactory); 101 | var omdbApi = new OmdbApi(loggerFactory); 102 | var imdbApi = new ImdbApi(loggerFactory); 103 | 104 | Task.Run(async () => 105 | { 106 | var provider = new SeasonProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 107 | var result = await provider.GuestDoubanSeasonByYearAsync("机动战士高达0083 星尘的回忆", 1991, null, CancellationToken.None); 108 | Assert.AreEqual(result, "1766564"); 109 | }).GetAwaiter().GetResult(); 110 | } 111 | 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/SeriesImageProviderTest.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Jellyfin.Plugin.MetaShark.Model; 4 | using Jellyfin.Plugin.MetaShark.Providers; 5 | using MediaBrowser.Controller.Library; 6 | using MediaBrowser.Controller.Providers; 7 | using MediaBrowser.Model.Entities; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.Extensions.Logging; 10 | using Moq; 11 | using System; 12 | using System.Collections.Generic; 13 | using System.Linq; 14 | using System.Text; 15 | using System.Threading.Tasks; 16 | 17 | namespace Jellyfin.Plugin.MetaShark.Test 18 | { 19 | [TestClass] 20 | public class SeriesImageProviderTest 21 | { 22 | 23 | ILoggerFactory loggerFactory = LoggerFactory.Create(builder => 24 | builder.AddSimpleConsole(options => 25 | { 26 | options.IncludeScopes = true; 27 | options.SingleLine = true; 28 | options.TimestampFormat = "hh:mm:ss "; 29 | })); 30 | 31 | [TestMethod] 32 | public void TestGetImages() 33 | { 34 | var info = new MediaBrowser.Controller.Entities.TV.Series() 35 | { 36 | Name = "花牌情缘", 37 | PreferredMetadataLanguage = "zh", 38 | ProviderIds = new Dictionary { { BaseProvider.DoubanProviderId, "6439459" }, { MetadataProvider.Tmdb.ToString(), "45247" } } 39 | }; 40 | var httpClientFactory = new DefaultHttpClientFactory(); 41 | var libraryManagerStub = new Mock(); 42 | var httpContextAccessorStub = new Mock(); 43 | var doubanApi = new DoubanApi(loggerFactory); 44 | var tmdbApi = new TmdbApi(loggerFactory); 45 | var omdbApi = new OmdbApi(loggerFactory); 46 | var imdbApi = new ImdbApi(loggerFactory); 47 | 48 | Task.Run(async () => 49 | { 50 | var provider = new SeriesImageProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 51 | var result = await provider.GetImages(info, CancellationToken.None); 52 | Assert.IsNotNull(result); 53 | 54 | var str = result.ToJson(); 55 | Console.WriteLine(result.ToJson()); 56 | }).GetAwaiter().GetResult(); 57 | } 58 | 59 | 60 | [TestMethod] 61 | public void TestGetImagesFromTMDB() 62 | { 63 | var info = new MediaBrowser.Controller.Entities.TV.Series() 64 | { 65 | PreferredMetadataLanguage = "zh", 66 | ProviderIds = new Dictionary { { MetadataProvider.Tmdb.ToString(), "67534" }, { Plugin.ProviderId, MetaSource.Tmdb.ToString() } } 67 | }; 68 | var httpClientFactory = new DefaultHttpClientFactory(); 69 | var libraryManagerStub = new Mock(); 70 | var httpContextAccessorStub = new Mock(); 71 | var doubanApi = new DoubanApi(loggerFactory); 72 | var tmdbApi = new TmdbApi(loggerFactory); 73 | var omdbApi = new OmdbApi(loggerFactory); 74 | var imdbApi = new ImdbApi(loggerFactory); 75 | 76 | Task.Run(async () => 77 | { 78 | var provider = new SeriesImageProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 79 | var result = await provider.GetImages(info, CancellationToken.None); 80 | Assert.IsNotNull(result); 81 | 82 | var str = result.ToJson(); 83 | Console.WriteLine(result.ToJson()); 84 | }).GetAwaiter().GetResult(); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/SeriesProviderTest.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Jellyfin.Plugin.MetaShark.Providers; 4 | using MediaBrowser.Controller.Library; 5 | using MediaBrowser.Controller.Providers; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.Extensions.Logging; 8 | using Moq; 9 | using System; 10 | using System.Collections.Generic; 11 | using System.Linq; 12 | using System.Text; 13 | using System.Threading.Tasks; 14 | 15 | namespace Jellyfin.Plugin.MetaShark.Test 16 | { 17 | [TestClass] 18 | public class SeriesProviderTest 19 | { 20 | 21 | ILoggerFactory loggerFactory = LoggerFactory.Create(builder => 22 | builder.AddSimpleConsole(options => 23 | { 24 | options.IncludeScopes = true; 25 | options.SingleLine = true; 26 | options.TimestampFormat = "hh:mm:ss "; 27 | })); 28 | 29 | 30 | [TestMethod] 31 | public void TestGetMetadata() 32 | { 33 | var info = new SeriesInfo() { Name = "天下长河" }; 34 | var httpClientFactory = new DefaultHttpClientFactory(); 35 | var libraryManagerStub = new Mock(); 36 | var httpContextAccessorStub = new Mock(); 37 | var doubanApi = new DoubanApi(loggerFactory); 38 | var tmdbApi = new TmdbApi(loggerFactory); 39 | var omdbApi = new OmdbApi(loggerFactory); 40 | var imdbApi = new ImdbApi(loggerFactory); 41 | 42 | Task.Run(async () => 43 | { 44 | var provider = new SeriesProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 45 | var result = await provider.GetMetadata(info, CancellationToken.None); 46 | Assert.IsNotNull(result); 47 | 48 | var str = result.ToJson(); 49 | Console.WriteLine(result.ToJson()); 50 | }).GetAwaiter().GetResult(); 51 | } 52 | 53 | [TestMethod] 54 | public void TestGetAnimeMetadata() 55 | { 56 | var info = new SeriesInfo() { Name = "命运-冠位嘉年华" }; 57 | var httpClientFactory = new DefaultHttpClientFactory(); 58 | var libraryManagerStub = new Mock(); 59 | var httpContextAccessorStub = new Mock(); 60 | var doubanApi = new DoubanApi(loggerFactory); 61 | var tmdbApi = new TmdbApi(loggerFactory); 62 | var omdbApi = new OmdbApi(loggerFactory); 63 | var imdbApi = new ImdbApi(loggerFactory); 64 | 65 | Task.Run(async () => 66 | { 67 | var provider = new SeriesProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi, imdbApi); 68 | var result = await provider.GetMetadata(info, CancellationToken.None); 69 | Assert.AreEqual(result.Item.Name, "命运/冠位指定嘉年华 公元2020奥林匹亚英灵限界大祭"); 70 | Assert.AreEqual(result.Item.OriginalTitle, "Fate/Grand Carnival"); 71 | Assert.IsNotNull(result); 72 | 73 | var str = result.ToJson(); 74 | Console.WriteLine(result.ToJson()); 75 | }).GetAwaiter().GetResult(); 76 | } 77 | 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/StringSimilarityTest.cs: -------------------------------------------------------------------------------- 1 | 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Emby.Naming.TV; 4 | 5 | namespace Jellyfin.Plugin.MetaShark.Test 6 | { 7 | [TestClass] 8 | public class StringSimilarityTest 9 | { 10 | [TestMethod] 11 | public void TestString() 12 | { 13 | 14 | var str1 = "雄狮少年"; 15 | var str2 = "我是特优声 剧团季"; 16 | 17 | var score = str1.Distance(str2); 18 | 19 | str1 = "雄狮少年"; 20 | str2 = "雄狮少年 第二季"; 21 | 22 | score = str1.Distance(str2); 23 | 24 | var score2 = "君子和而不同".Distance("小人同而不和"); 25 | 26 | Assert.IsTrue(score > 0); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/TmdbApiTest.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Jellyfin.Plugin.MetaShark.Providers; 4 | using Microsoft.Extensions.Logging; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Text; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | using TMDbLib.Objects.Find; 12 | using TMDbLib.Objects.Languages; 13 | 14 | namespace Jellyfin.Plugin.MetaShark.Test 15 | { 16 | [TestClass] 17 | public class TmdbApiTest 18 | { 19 | private TestContext testContextInstance; 20 | 21 | /// 22 | /// Gets or sets the test context which provides 23 | /// information about and functionality for the current test run. 24 | /// 25 | public TestContext TestContext 26 | { 27 | get { return testContextInstance; } 28 | set { testContextInstance = value; } 29 | } 30 | 31 | ILoggerFactory loggerFactory = LoggerFactory.Create(builder => 32 | builder.AddSimpleConsole(options => 33 | { 34 | options.IncludeScopes = true; 35 | options.SingleLine = true; 36 | options.TimestampFormat = "hh:mm:ss "; 37 | })); 38 | 39 | 40 | [TestMethod] 41 | public void TestGetMovie() 42 | { 43 | var api = new TmdbApi(loggerFactory); 44 | 45 | Task.Run(async () => 46 | { 47 | try 48 | { 49 | var result = await api.GetMovieAsync(752, "zh", "zh", CancellationToken.None) 50 | .ConfigureAwait(false); 51 | Assert.IsNotNull(result); 52 | TestContext.WriteLine(result.Images.ToJson()); 53 | } 54 | catch (Exception ex) 55 | { 56 | TestContext.WriteLine(ex.Message); 57 | } 58 | }).GetAwaiter().GetResult(); 59 | } 60 | 61 | 62 | [TestMethod] 63 | public void TestGetSeries() 64 | { 65 | var api = new TmdbApi(loggerFactory); 66 | 67 | Task.Run(async () => 68 | { 69 | try 70 | { 71 | var result = await api.GetSeriesAsync(13372, "zh", "zh", CancellationToken.None) 72 | .ConfigureAwait(false); 73 | Assert.IsNotNull(result); 74 | TestContext.WriteLine(result.Images.ToJson()); 75 | } 76 | catch (Exception ex) 77 | { 78 | TestContext.WriteLine(ex.Message); 79 | } 80 | }).GetAwaiter().GetResult(); 81 | } 82 | 83 | 84 | [TestMethod] 85 | public void TestGetEpisode() 86 | { 87 | var api = new TmdbApi(loggerFactory); 88 | 89 | Task.Run(async () => 90 | { 91 | try 92 | { 93 | var result = await api.GetEpisodeAsync(13372, 1, 1, "zh", "zh", CancellationToken.None) 94 | .ConfigureAwait(false); 95 | Assert.IsNotNull(result); 96 | TestContext.WriteLine(result.Images.Stills.ToJson()); 97 | } 98 | catch (Exception ex) 99 | { 100 | TestContext.WriteLine(ex.Message); 101 | } 102 | }).GetAwaiter().GetResult(); 103 | } 104 | 105 | 106 | 107 | [TestMethod] 108 | public void TestSearch() 109 | { 110 | var keyword = "狼与香辛料"; 111 | var api = new TmdbApi(loggerFactory); 112 | 113 | Task.Run(async () => 114 | { 115 | try 116 | { 117 | var result = await api.SearchSeriesAsync(keyword, "zh", CancellationToken.None).ConfigureAwait(false); 118 | Assert.IsNotNull(result); 119 | TestContext.WriteLine(result.ToJson()); 120 | } 121 | catch (Exception ex) 122 | { 123 | TestContext.WriteLine(ex.Message); 124 | } 125 | }).GetAwaiter().GetResult(); 126 | } 127 | 128 | 129 | [TestMethod] 130 | public void TestFindByExternalId() 131 | { 132 | var api = new TmdbApi(loggerFactory); 133 | 134 | Task.Run(async () => 135 | { 136 | try 137 | { 138 | var result = await api.FindByExternalIdAsync("tt5924366", FindExternalSource.Imdb, "zh", CancellationToken.None) 139 | .ConfigureAwait(false); 140 | Assert.IsNotNull(result); 141 | TestContext.WriteLine(result.ToJson()); 142 | } 143 | catch (Exception ex) 144 | { 145 | TestContext.WriteLine(ex.Message); 146 | } 147 | }).GetAwaiter().GetResult(); 148 | } 149 | 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.Test/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Microsoft.VisualStudio.TestTools.UnitTesting; -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.3.32922.545 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Plugin.MetaShark", "Jellyfin.Plugin.MetaShark\Jellyfin.Plugin.MetaShark.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}" 6 | EndProject 7 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Plugin.MetaShark.Test", "Jellyfin.Plugin.MetaShark.Test\Jellyfin.Plugin.MetaShark.Test.csproj", "{80814353-4291-4230-8C4A-4C45CAD4D5D3}" 8 | EndProject 9 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AnitomySharp", "AnitomySharp\AnitomySharp.csproj", "{B6FC3E72-D30D-49D3-B545-0EF0CCFF220A}" 10 | EndProject 11 | Global 12 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 13 | Debug|Any CPU = Debug|Any CPU 14 | Release|Any CPU = Release|Any CPU 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|Any CPU.Build.0 = Release|Any CPU 21 | {80814353-4291-4230-8C4A-4C45CAD4D5D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {80814353-4291-4230-8C4A-4C45CAD4D5D3}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {80814353-4291-4230-8C4A-4C45CAD4D5D3}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {80814353-4291-4230-8C4A-4C45CAD4D5D3}.Release|Any CPU.Build.0 = Release|Any CPU 25 | {B6FC3E72-D30D-49D3-B545-0EF0CCFF220A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {B6FC3E72-D30D-49D3-B545-0EF0CCFF220A}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {B6FC3E72-D30D-49D3-B545-0EF0CCFF220A}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {B6FC3E72-D30D-49D3-B545-0EF0CCFF220A}.Release|Any CPU.Build.0 = Release|Any CPU 29 | EndGlobalSection 30 | GlobalSection(SolutionProperties) = preSolution 31 | HideSolutionNode = FALSE 32 | EndGlobalSection 33 | GlobalSection(ExtensibilityGlobals) = postSolution 34 | SolutionGuid = {7D81AE36-16A1-4386-8B86-21FACCB675DF} 35 | EndGlobalSection 36 | EndGlobal 37 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Api/Http/HttpClientHandlerEx.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Text; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | 10 | namespace Jellyfin.Plugin.MetaShark.Api.Http 11 | { 12 | public class HttpClientHandlerEx : HttpClientHandler 13 | { 14 | public HttpClientHandlerEx() 15 | { 16 | // 忽略SSL证书问题 17 | ServerCertificateCustomValidationCallback = (message, certificate2, arg3, arg4) => true; 18 | AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; 19 | CookieContainer = new CookieContainer(); 20 | UseCookies = true; 21 | } 22 | 23 | protected override Task SendAsync( 24 | HttpRequestMessage request, CancellationToken cancellationToken) 25 | { 26 | return base.SendAsync(request, cancellationToken); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Api/Http/LoggingHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Text; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Jellyfin.Plugin.MetaShark.Api.Http 12 | { 13 | public class LoggingHandler : DelegatingHandler 14 | { 15 | private readonly ILogger _logger; 16 | public LoggingHandler(HttpMessageHandler innerHandler, ILoggerFactory loggerFactory) 17 | : base(innerHandler) 18 | { 19 | _logger = loggerFactory.CreateLogger(); 20 | } 21 | 22 | protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 23 | { 24 | _logger.LogInformation((request.RequestUri?.ToString() ?? string.Empty)); 25 | 26 | return await base.SendAsync(request, cancellationToken); 27 | } 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Api/ImdbApi.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Core; 2 | using Microsoft.Extensions.Caching.Memory; 3 | using Microsoft.Extensions.Logging; 4 | using System; 5 | using System.Linq; 6 | using System.Net.Http; 7 | using System.Text.RegularExpressions; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | namespace Jellyfin.Plugin.MetaShark.Api 12 | { 13 | public class ImdbApi : IDisposable 14 | { 15 | private readonly ILogger _logger; 16 | private readonly IMemoryCache _memoryCache; 17 | private readonly HttpClient httpClient; 18 | 19 | Regex regId = new Regex(@"/(tt\d+)", RegexOptions.Compiled); 20 | Regex regPersonId = new Regex(@"/(nm\d+)", RegexOptions.Compiled); 21 | 22 | public ImdbApi(ILoggerFactory loggerFactory) 23 | { 24 | _logger = loggerFactory.CreateLogger(); 25 | _memoryCache = new MemoryCache(new MemoryCacheOptions()); 26 | 27 | var handler = new HttpClientHandler() 28 | { 29 | AllowAutoRedirect = false 30 | }; 31 | httpClient = new HttpClient(handler); 32 | httpClient.Timeout = TimeSpan.FromSeconds(10); 33 | } 34 | 35 | /// 36 | /// 通过imdb获取信息(会返回最新的imdb id) 37 | /// 38 | public async Task CheckNewIDAsync(string id, CancellationToken cancellationToken) 39 | { 40 | var cacheKey = $"CheckNewImdbID_{id}"; 41 | var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }; 42 | if (this._memoryCache.TryGetValue(cacheKey, out var item)) 43 | { 44 | return item; 45 | } 46 | 47 | try 48 | { 49 | var url = $"https://www.imdb.com/title/{id}/"; 50 | var resp = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); 51 | if (resp.Headers.TryGetValues("Location", out var values)) 52 | { 53 | var location = values.First(); 54 | var newId = location.GetMatchGroup(this.regId); 55 | if (!string.IsNullOrEmpty(newId)) 56 | { 57 | item = newId; 58 | } 59 | } 60 | this._memoryCache.Set(cacheKey, item, expiredOption); 61 | return item; 62 | } 63 | catch (Exception ex) 64 | { 65 | this._logger.LogError(ex, "CheckNewImdbID error. id: {0}", id); 66 | this._memoryCache.Set(cacheKey, null, expiredOption); 67 | return null; 68 | } 69 | 70 | return null; 71 | } 72 | 73 | /// 74 | /// 通过imdb获取信息(会返回最新的imdb id) 75 | /// 76 | public async Task CheckPersonNewIDAsync(string id, CancellationToken cancellationToken) 77 | { 78 | var cacheKey = $"CheckPersonNewImdbID_{id}"; 79 | var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }; 80 | if (this._memoryCache.TryGetValue(cacheKey, out var item)) 81 | { 82 | return item; 83 | } 84 | 85 | try 86 | { 87 | var url = $"https://www.imdb.com/name/{id}/"; 88 | var resp = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); 89 | if (resp.Headers.TryGetValues("Location", out var values)) 90 | { 91 | var location = values.First(); 92 | var newId = location.GetMatchGroup(this.regPersonId); 93 | if (!string.IsNullOrEmpty(newId)) 94 | { 95 | item = newId; 96 | } 97 | } 98 | this._memoryCache.Set(cacheKey, item, expiredOption); 99 | return item; 100 | } 101 | catch (Exception ex) 102 | { 103 | this._logger.LogError(ex, "CheckPersonNewImdbID error. id: {0}", id); 104 | this._memoryCache.Set(cacheKey, null, expiredOption); 105 | return null; 106 | } 107 | } 108 | 109 | 110 | public void Dispose() 111 | { 112 | Dispose(true); 113 | GC.SuppressFinalize(this); 114 | } 115 | 116 | protected virtual void Dispose(bool disposing) 117 | { 118 | if (disposing) 119 | { 120 | _memoryCache.Dispose(); 121 | } 122 | } 123 | 124 | private bool IsEnable() 125 | { 126 | return Plugin.Instance?.Configuration.EnableTmdb ?? true; 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Api/OmdbApi.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Model; 2 | using Microsoft.Extensions.Caching.Memory; 3 | using Microsoft.Extensions.Logging; 4 | using System; 5 | using System.Net.Http; 6 | using System.Net.Http.Json; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | 10 | namespace Jellyfin.Plugin.MetaShark.Api 11 | { 12 | public class OmdbApi : IDisposable 13 | { 14 | public const string DEFAULT_API_KEY = "2c9d9507"; 15 | private readonly ILogger _logger; 16 | private readonly IMemoryCache _memoryCache; 17 | private readonly HttpClient httpClient; 18 | 19 | public OmdbApi(ILoggerFactory loggerFactory) 20 | { 21 | _logger = loggerFactory.CreateLogger(); 22 | _memoryCache = new MemoryCache(new MemoryCacheOptions()); 23 | httpClient = new HttpClient(); 24 | httpClient.Timeout = TimeSpan.FromSeconds(5); 25 | } 26 | 27 | /// 28 | /// 通过imdb获取信息(会返回最新的imdb id) 29 | /// 30 | /// imdb id 31 | /// 32 | /// 33 | public async Task GetByImdbID(string id, CancellationToken cancellationToken) 34 | { 35 | if (!this.IsEnable()) 36 | { 37 | return null; 38 | } 39 | 40 | var cacheKey = $"GetByImdbID_{id}"; 41 | var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }; 42 | if (this._memoryCache.TryGetValue(cacheKey, out var item)) 43 | { 44 | return item; 45 | } 46 | 47 | try 48 | { 49 | var url = $"https://www.omdbapi.com/?i={id}&apikey={DEFAULT_API_KEY}"; 50 | item = await this.httpClient.GetFromJsonAsync(url, cancellationToken).ConfigureAwait(false); 51 | _memoryCache.Set(cacheKey, item, expiredOption); 52 | return item; 53 | } 54 | catch (Exception ex) 55 | { 56 | this._logger.LogError(ex, "GetByImdbID error. id: {0}", id); 57 | _memoryCache.Set(cacheKey, null, expiredOption); 58 | return null; 59 | } 60 | } 61 | 62 | 63 | public void Dispose() 64 | { 65 | Dispose(true); 66 | GC.SuppressFinalize(this); 67 | } 68 | 69 | protected virtual void Dispose(bool disposing) 70 | { 71 | if (disposing) 72 | { 73 | _memoryCache.Dispose(); 74 | } 75 | } 76 | 77 | private bool IsEnable() 78 | { 79 | return Plugin.Instance?.Configuration.EnableTmdb ?? true; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/BoxSetManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using AngleSharp.Text; 8 | using Jellyfin.Data.Enums; 9 | using Jellyfin.Plugin.MetaShark.Core; 10 | using MediaBrowser.Controller.Collections; 11 | using MediaBrowser.Controller.Entities; 12 | using MediaBrowser.Controller.Entities.Movies; 13 | using MediaBrowser.Controller.Library; 14 | using MediaBrowser.Model.Entities; 15 | using Microsoft.Extensions.Hosting; 16 | using Microsoft.Extensions.Logging; 17 | 18 | namespace Jellyfin.Plugin.MetaShark; 19 | 20 | public class BoxSetManager : IHostedService 21 | { 22 | private readonly ILibraryManager _libraryManager; 23 | private readonly ICollectionManager _collectionManager; 24 | private readonly Timer _timer; 25 | private readonly HashSet _queuedTmdbCollection; 26 | private readonly ILogger _logger; // TODO logging 27 | 28 | public BoxSetManager(ILibraryManager libraryManager, ICollectionManager collectionManager, ILoggerFactory loggerFactory) 29 | { 30 | _libraryManager = libraryManager; 31 | _collectionManager = collectionManager; 32 | _logger = loggerFactory.CreateLogger(); 33 | _timer = new Timer(_ => OnTimerElapsed(), null, Timeout.Infinite, Timeout.Infinite); 34 | _queuedTmdbCollection = new HashSet(); 35 | } 36 | 37 | 38 | public async Task ScanLibrary(IProgress progress) 39 | { 40 | var startIndex = 0; 41 | var pagesize = 1000; 42 | 43 | if (!(Plugin.Instance?.Configuration.EnableTmdbCollection ?? false)) 44 | { 45 | _logger.LogInformation("插件配置中未打开自动创建合集功能"); 46 | progress?.Report(100); 47 | return; 48 | } 49 | 50 | var boxSets = GetAllBoxSetsFromLibrary(); 51 | var movieCollections = GetMoviesFromLibrary(); 52 | 53 | _logger.LogInformation("共找到 {Count} 个合集信息", movieCollections.Count); 54 | int index = 0; 55 | foreach (var (collectionName, collectionMovies) in movieCollections) 56 | { 57 | progress?.Report(100.0 * index / movieCollections.Count); 58 | 59 | var boxSet = boxSets.FirstOrDefault(b => b?.Name == collectionName); 60 | await AddMoviesToCollection(collectionMovies, collectionName, boxSet).ConfigureAwait(false); 61 | index++; 62 | } 63 | 64 | progress?.Report(100); 65 | } 66 | 67 | private async Task AddMoviesToCollection(IList movies, string collectionName, BoxSet boxSet) 68 | { 69 | if (movies.Count < 2) 70 | { 71 | // won't automatically create collection if only one movie in it 72 | return; 73 | } 74 | 75 | var movieIds = movies.Select(m => m.Id).ToList(); 76 | if (boxSet is null) 77 | { 78 | _logger.LogInformation("创建合集 [{collectionName}],添加电影:{moviesNames}", collectionName, movies.Select(m => m.Name).Aggregate((a, b) => a + ", " + b)); 79 | boxSet = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions 80 | { 81 | Name = collectionName, 82 | }); 83 | 84 | await _collectionManager.AddToCollectionAsync(boxSet.Id, movieIds); 85 | 86 | // HACK: 等获取 boxset 元数据后再更新一次合集,用于修正刷新元数据后丢失关联电影的 BUG 87 | _queuedTmdbCollection.Add(collectionName); 88 | _timer.Change(60000, Timeout.Infinite); 89 | } 90 | else 91 | { 92 | _logger.LogInformation("更新合集 [{collectionName}],添加电影:{moviesNames}", collectionName, movies.Select(m => m.Name).Aggregate((a, b) => a + ", " + b)); 93 | await _collectionManager.AddToCollectionAsync(boxSet.Id, movieIds); 94 | } 95 | } 96 | 97 | 98 | private IReadOnlyCollection GetAllBoxSetsFromLibrary() 99 | { 100 | return _libraryManager.GetItemList(new InternalItemsQuery 101 | { 102 | IncludeItemTypes = new[] { BaseItemKind.BoxSet }, 103 | CollapseBoxSetItems = false, 104 | Recursive = true 105 | }).Select(b => b as BoxSet).ToList(); 106 | } 107 | 108 | 109 | public IDictionary> GetMoviesFromLibrary() 110 | { 111 | var collectionMoviesMap = new Dictionary>(); 112 | 113 | foreach (var library in _libraryManager.RootFolder.Children) 114 | { 115 | // 判断当前是媒体库是否是电影,并开启了 metashark 插件 116 | var typeOptions = _libraryManager.GetLibraryOptions(library).TypeOptions; 117 | if (typeOptions.FirstOrDefault(x => x.Type == "Movie" && x.MetadataFetchers.Contains(Plugin.PluginName)) == null) 118 | { 119 | continue; 120 | } 121 | 122 | var startIndex = 0; 123 | var pagesize = 1000; 124 | 125 | while (true) 126 | { 127 | var movies = _libraryManager.GetItemList(new InternalItemsQuery 128 | { 129 | IncludeItemTypes = new[] { BaseItemKind.Movie }, 130 | IsVirtualItem = false, 131 | OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, 132 | Parent = library, 133 | StartIndex = startIndex, 134 | Limit = pagesize, 135 | Recursive = true, 136 | HasTmdbId = true 137 | }).Select(b => b as Movie).ToList(); 138 | 139 | foreach (var movie in movies) 140 | { 141 | if (string.IsNullOrEmpty(movie.CollectionName)) 142 | { 143 | continue; 144 | } 145 | 146 | if (collectionMoviesMap.TryGetValue(movie.CollectionName, out var movieList)) 147 | { 148 | movieList.Add(movie); 149 | } 150 | else 151 | { 152 | collectionMoviesMap[movie.CollectionName] = new List() { movie }; 153 | } 154 | } 155 | 156 | if (movies.Count < pagesize) 157 | { 158 | break; 159 | } 160 | 161 | startIndex += pagesize; 162 | } 163 | } 164 | 165 | return collectionMoviesMap; 166 | } 167 | 168 | private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs e) 169 | { 170 | if (!(Plugin.Instance?.Configuration.EnableTmdbCollection ?? false)) 171 | { 172 | return; 173 | } 174 | 175 | // Only support movies at this time 176 | if (e.Item is not Movie movie || e.Item.LocationType == LocationType.Virtual) 177 | { 178 | return; 179 | } 180 | 181 | if (string.IsNullOrEmpty(movie.CollectionName)) 182 | { 183 | return; 184 | } 185 | 186 | // 判断 item 所在的媒体库是否是电影,并开启了 metashark 插件 187 | var typeOptions = _libraryManager.GetLibraryOptions(movie).TypeOptions; 188 | if (typeOptions.FirstOrDefault(x => x.Type == "Movie" && x.MetadataFetchers.Contains(Plugin.PluginName)) == null) 189 | { 190 | return; 191 | } 192 | 193 | _queuedTmdbCollection.Add(movie.CollectionName); 194 | 195 | // Restart the timer. After idling for 60 seconds it should trigger the callback. This is to avoid clobbering during a large library update. 196 | _timer.Change(60000, Timeout.Infinite); 197 | } 198 | 199 | private void OnTimerElapsed() 200 | { 201 | // Stop the timer until next update 202 | _timer.Change(Timeout.Infinite, Timeout.Infinite); 203 | 204 | var tmdbCollectionNames = _queuedTmdbCollection.ToArray(); 205 | // Clear the queue now, TODO what if it crashes? Should it be cleared after it's done? 206 | _queuedTmdbCollection.Clear(); 207 | 208 | var boxSets = GetAllBoxSetsFromLibrary(); 209 | var movies = GetMoviesFromLibrary(); 210 | foreach (var collectionName in tmdbCollectionNames) 211 | { 212 | if (movies.TryGetValue(collectionName, out var collectionMovies)) 213 | { 214 | var boxSet = boxSets.FirstOrDefault(b => b?.Name == collectionName); 215 | AddMoviesToCollection(collectionMovies, collectionName, boxSet).GetAwaiter().GetResult(); 216 | } 217 | } 218 | } 219 | 220 | public Task StartAsync(CancellationToken cancellationToken) 221 | { 222 | _libraryManager.ItemUpdated += OnLibraryManagerItemUpdated; 223 | return Task.CompletedTask; 224 | } 225 | 226 | public Task StopAsync(CancellationToken cancellationToken) 227 | { 228 | _libraryManager.ItemUpdated -= OnLibraryManagerItemUpdated; 229 | return Task.CompletedTask; 230 | } 231 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Configuration/PluginConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Reflection; 3 | using MediaBrowser.Model.Plugins; 4 | 5 | namespace Jellyfin.Plugin.MetaShark.Configuration; 6 | 7 | 8 | /// 9 | /// Plugin configuration. 10 | /// 11 | public class PluginConfiguration : BasePluginConfiguration 12 | { 13 | public const int MAX_CAST_MEMBERS = 15; 14 | public const int MAX_SEARCH_RESULT = 5; 15 | 16 | /// 17 | /// 插件版本 18 | /// 19 | public string Version { get; } = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? string.Empty; 20 | 21 | public string DoubanCookies { get; set; } = string.Empty; 22 | /// 23 | /// 豆瓣开启防封禁 24 | /// 25 | public bool EnableDoubanAvoidRiskControl { get; set; } = false; 26 | /// 27 | /// 豆瓣海报使用大图 28 | /// 29 | public bool EnableDoubanLargePoster { get; set; } = true; 30 | /// 31 | /// 豆瓣背景图使用原图 32 | /// 33 | public bool EnableDoubanBackdropRaw { get; set; } = false; 34 | /// 35 | /// 豆瓣图片代理地址 36 | /// 37 | public string DoubanImageProxyBaseUrl { get; set; } = string.Empty; 38 | 39 | /// 40 | /// 启用获取tmdb元数据 41 | /// 42 | public bool EnableTmdb { get; set; } = true; 43 | 44 | /// 45 | /// 启用显示tmdb搜索结果 46 | /// 47 | public bool EnableTmdbSearch { get; set; } = false; 48 | 49 | /// 50 | /// 启用tmdb获取背景图 51 | /// 52 | public bool EnableTmdbBackdrop { get; set; } = true; 53 | 54 | /// 55 | /// 启用tmdb获取商标 56 | /// 57 | public bool EnableTmdbLogo { get; set; } = true; 58 | 59 | /// 60 | /// 是否根据电影系列自动创建合集 61 | /// 62 | public bool EnableTmdbCollection { get; set; } = true; 63 | /// 64 | /// 是否获取tmdb分级信息 65 | /// 66 | public bool EnableTmdbOfficialRating { get; set; } = true; 67 | /// 68 | /// tmdb api key 69 | /// 70 | public string TmdbApiKey { get; set; } = string.Empty; 71 | /// 72 | /// tmdb api host 73 | /// 74 | public string TmdbHost { get; set; } = string.Empty; 75 | /// 76 | /// 代理服务器类型,0-禁用,1-http,2-https,3-socket5 77 | /// 78 | public string TmdbProxyType { get; set; } = string.Empty; 79 | /// 80 | /// 代理服务器host 81 | /// 82 | public string TmdbProxyPort { get; set; } = string.Empty; 83 | /// 84 | /// 代理服务器端口 85 | /// 86 | public string TmdbProxyHost { get; set; } = string.Empty; 87 | 88 | 89 | public IWebProxy GetTmdbWebProxy() 90 | { 91 | 92 | if (!string.IsNullOrEmpty(TmdbProxyType)) 93 | { 94 | return new WebProxy($"{TmdbProxyType}://{TmdbProxyHost}:{TmdbProxyPort}", true); 95 | } 96 | 97 | return null; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Controllers/ApiController.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Linq; 3 | using System.Net.Http; 4 | using System.IO; 5 | using System.Threading.Tasks; 6 | using MediaBrowser.Common.Extensions; 7 | using Microsoft.AspNetCore.Authorization; 8 | using Microsoft.AspNetCore.Mvc; 9 | using MediaBrowser.Common.Net; 10 | using Jellyfin.Plugin.MetaShark.Api; 11 | using Jellyfin.Plugin.MetaShark.Model; 12 | 13 | namespace Jellyfin.Plugin.MetaShark.Controllers 14 | { 15 | [ApiController] 16 | [AllowAnonymous] 17 | [Route("/plugin/metashark")] 18 | public class ApiController : ControllerBase 19 | { 20 | private readonly DoubanApi _doubanApi; 21 | private readonly IHttpClientFactory _httpClientFactory; 22 | 23 | /// 24 | /// Initializes a new instance of the class. 25 | /// 26 | /// The . 27 | public ApiController(IHttpClientFactory httpClientFactory, DoubanApi doubanApi) 28 | { 29 | this._httpClientFactory = httpClientFactory; 30 | this._doubanApi = doubanApi; 31 | } 32 | 33 | 34 | /// 35 | /// 代理访问图片. 36 | /// 37 | [Route("proxy/image")] 38 | [HttpGet] 39 | public async Task ProxyImage(string url) 40 | { 41 | 42 | if (string.IsNullOrEmpty(url)) 43 | { 44 | throw new ResourceNotFoundException(); 45 | } 46 | 47 | HttpResponseMessage response; 48 | var httpClient = GetHttpClient(); 49 | using (var requestMessage = new HttpRequestMessage(HttpMethod.Get, url)) 50 | { 51 | requestMessage.Headers.Add("User-Agent", DoubanApi.HTTP_USER_AGENT); 52 | requestMessage.Headers.Add("Referer", DoubanApi.HTTP_REFERER); 53 | 54 | response = await httpClient.SendAsync(requestMessage); 55 | } 56 | var stream = await response.Content.ReadAsStreamAsync(); 57 | 58 | Response.StatusCode = (int)response.StatusCode; 59 | if (response.Content.Headers.ContentType != null) 60 | { 61 | Response.ContentType = response.Content.Headers.ContentType.ToString(); 62 | } 63 | Response.ContentLength = response.Content.Headers.ContentLength; 64 | 65 | foreach (var header in response.Headers) 66 | { 67 | Response.Headers.Add(header.Key, header.Value.First()); 68 | } 69 | 70 | return stream; 71 | } 72 | 73 | /// 74 | /// 检查豆瓣cookie是否失效. 75 | /// 76 | [Route("douban/checklogin")] 77 | [HttpGet] 78 | public async Task CheckDoubanLogin() 79 | { 80 | var loginInfo = await this._doubanApi.GetLoginInfoAsync(CancellationToken.None).ConfigureAwait(false); 81 | return new ApiResult(loginInfo.IsLogined ? 1 : 0, loginInfo.Name); 82 | } 83 | 84 | 85 | private HttpClient GetHttpClient() 86 | { 87 | var client = _httpClientFactory.CreateClient(NamedClient.Default); 88 | return client; 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Core/ElementExtension.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp.Dom; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace Jellyfin.Plugin.MetaShark.Core 9 | { 10 | public static class ElementExtension 11 | { 12 | public static string? GetText(this IElement el, string css) 13 | { 14 | var node = el.QuerySelector(css); 15 | if (node != null) 16 | { 17 | return node.Text().Trim(); 18 | } 19 | 20 | return null; 21 | } 22 | 23 | public static string? GetHtml(this IElement el, string css) 24 | { 25 | var node = el.QuerySelector(css); 26 | if (node != null) 27 | { 28 | return node.Html().Trim(); 29 | } 30 | 31 | return null; 32 | } 33 | 34 | public static string GetTextOrDefault(this IElement el, string css, string defaultVal = "") 35 | { 36 | var node = el.QuerySelector(css); 37 | if (node != null) 38 | { 39 | return node.Text().Trim(); 40 | } 41 | 42 | return defaultVal; 43 | } 44 | 45 | public static string? GetAttr(this IElement el, string css, string attr) 46 | { 47 | var node = el.QuerySelector(css); 48 | if (node != null) 49 | { 50 | var attrVal = node.GetAttribute(attr); 51 | return attrVal != null ? attrVal.Trim() : null; 52 | } 53 | 54 | return null; 55 | } 56 | 57 | public static string? GetAttrOrDefault(this IElement el, string css, string attr, string defaultVal = "") 58 | { 59 | var node = el.QuerySelector(css); 60 | if (node != null) 61 | { 62 | var attrVal = node.GetAttribute(attr); 63 | return attrVal != null ? attrVal.Trim() : defaultVal; 64 | } 65 | 66 | return defaultVal; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Core/JsonExtension.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp.Dom; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Text.Json; 7 | using System.Threading.Tasks; 8 | 9 | namespace Jellyfin.Plugin.MetaShark.Core 10 | { 11 | public static class JsonExtension 12 | { 13 | public static string ToJson(this object obj) 14 | { 15 | if (obj == null) return string.Empty; 16 | 17 | return JsonSerializer.Serialize(obj); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Core/ListExtension.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace Jellyfin.Plugin.MetaShark.Core 8 | { 9 | public static class ListExtension 10 | { 11 | public static IEnumerable<(T item, int index)> WithIndex(this IEnumerable self) 12 | => self.Select((item, index) => (item, index)); 13 | 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Core/RegexExtension.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.Eventing.Reader; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Text.RegularExpressions; 7 | using System.Threading.Tasks; 8 | using StringMetric; 9 | 10 | namespace Jellyfin.Plugin.MetaShark.Core 11 | { 12 | public static class RegexExtension 13 | { 14 | public static string FirstMatchGroup(this Regex reg, string text, string defalutVal = "") 15 | { 16 | var match = reg.Match(text); 17 | if (match.Success && match.Groups.Count > 1) 18 | { 19 | return match.Groups[1].Value.Trim(); 20 | } 21 | 22 | return defalutVal; 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Core/StringExtension.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.Eventing.Reader; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Text.RegularExpressions; 7 | using System.Threading.Tasks; 8 | using StringMetric; 9 | 10 | namespace Jellyfin.Plugin.MetaShark.Core 11 | { 12 | public static class StringExtension 13 | { 14 | public static long ToLong(this string s) 15 | { 16 | long val; 17 | if (long.TryParse(s, out val)) 18 | { 19 | return val; 20 | } 21 | 22 | return 0; 23 | } 24 | 25 | public static int ToInt(this string s) 26 | { 27 | int val; 28 | if (int.TryParse(s, out val)) 29 | { 30 | return val; 31 | } 32 | 33 | return 0; 34 | } 35 | 36 | public static float ToFloat(this string s) 37 | { 38 | float val; 39 | if (float.TryParse(s, out val)) 40 | { 41 | return val; 42 | } 43 | 44 | return 0.0f; 45 | } 46 | 47 | public static bool IsChinese(this string s) 48 | { 49 | Regex chineseReg = new Regex(@"[\u4e00-\u9fa5:]{1,}", RegexOptions.Compiled); 50 | return chineseReg.IsMatch(s.Replace(" ", string.Empty).Trim()); 51 | } 52 | 53 | public static bool HasChinese(this string s) 54 | { 55 | Regex chineseReg = new Regex(@"[\u4e00-\u9fa5]", RegexOptions.Compiled); 56 | return chineseReg.Match(s).Success; 57 | } 58 | 59 | public static bool IsSameLanguage(this string s1, string s2) 60 | { 61 | return s1.IsChinese() == s2.IsChinese(); 62 | } 63 | 64 | public static double Distance(this string s1, string s2) 65 | { 66 | var jw = new JaroWinkler(); 67 | 68 | return jw.Similarity(s1, s2); 69 | } 70 | 71 | public static string GetMatchGroup(this string text, Regex reg) 72 | { 73 | var match = reg.Match(text); 74 | if (match.Success && match.Groups.Count > 1) 75 | { 76 | return match.Groups[1].Value.Trim(); 77 | } 78 | 79 | return string.Empty; 80 | } 81 | 82 | public static bool IsNumericString(this string str) 83 | { 84 | return str.All(char.IsDigit); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Core/StringMetric/JaroWinkler.cs: -------------------------------------------------------------------------------- 1 | /* 2 | * The MIT License 3 | * 4 | * Copyright 2016 feature[23] 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | using System; 26 | using System.Linq; 27 | // ReSharper disable SuggestVarOrType_Elsewhere 28 | // ReSharper disable LoopCanBeConvertedToQuery 29 | 30 | namespace StringMetric 31 | { 32 | /// The Jaro–Winkler distance metric is designed and best suited for short 33 | /// strings such as person names, and to detect typos; it is (roughly) a 34 | /// variation of Damerau-Levenshtein, where the substitution of 2 close 35 | /// characters is considered less important then the substitution of 2 characters 36 | /// that a far from each other. 37 | /// Jaro-Winkler was developed in the area of record linkage (duplicate 38 | /// detection) (Winkler, 1990). It returns a value in the interval [0.0, 1.0]. 39 | /// The distance is computed as 1 - Jaro-Winkler similarity. 40 | public class JaroWinkler 41 | { 42 | private const double DEFAULT_THRESHOLD = 0.7; 43 | private const int THREE = 3; 44 | private const double JW_COEF = 0.1; 45 | 46 | /// 47 | /// The current value of the threshold used for adding the Winkler bonus. The default value is 0.7. 48 | /// 49 | private double Threshold { get; } 50 | 51 | /// 52 | /// Creates a new instance with default threshold (0.7) 53 | /// 54 | public JaroWinkler() 55 | { 56 | Threshold = DEFAULT_THRESHOLD; 57 | } 58 | 59 | /// 60 | /// Creates a new instance with given threshold to determine when Winkler bonus should 61 | /// be used. Set threshold to a negative value to get the Jaro distance. 62 | /// 63 | /// 64 | public JaroWinkler(double threshold) 65 | { 66 | Threshold = threshold; 67 | } 68 | 69 | /// 70 | /// Compute Jaro-Winkler similarity. 71 | /// 72 | /// The first string to compare. 73 | /// The second string to compare. 74 | /// The Jaro-Winkler similarity in the range [0, 1] 75 | /// If s1 or s2 is null. 76 | public double Similarity(string s1, string s2) 77 | { 78 | if (s1 == null) 79 | { 80 | throw new ArgumentNullException(nameof(s1)); 81 | } 82 | 83 | if (s2 == null) 84 | { 85 | throw new ArgumentNullException(nameof(s2)); 86 | } 87 | 88 | if (s1.Equals(s2)) 89 | { 90 | return 1f; 91 | } 92 | 93 | int[] mtp = Matches(s1, s2); 94 | float m = mtp[0]; 95 | if (m == 0) 96 | { 97 | return 0f; 98 | } 99 | double j = ((m / s1.Length + m / s2.Length + (m - mtp[1]) / m)) 100 | / THREE; 101 | double jw = j; 102 | 103 | if (j > Threshold) 104 | { 105 | jw = j + Math.Min(JW_COEF, 1.0 / mtp[THREE]) * mtp[2] * (1 - j); 106 | } 107 | return jw; 108 | } 109 | 110 | /// 111 | /// Return 1 - similarity. 112 | /// 113 | /// The first string to compare. 114 | /// The second string to compare. 115 | /// 1 - similarity 116 | /// If s1 or s2 is null. 117 | public double Distance(string s1, string s2) 118 | => 1.0 - Similarity(s1, s2); 119 | 120 | private static int[] Matches(string s1, string s2) 121 | { 122 | string max, min; 123 | if (s1.Length > s2.Length) 124 | { 125 | max = s1; 126 | min = s2; 127 | } 128 | else 129 | { 130 | max = s2; 131 | min = s1; 132 | } 133 | int range = Math.Max(max.Length / 2 - 1, 0); 134 | 135 | //int[] matchIndexes = new int[min.Length]; 136 | //Arrays.fill(matchIndexes, -1); 137 | int[] match_indexes = Enumerable.Repeat(-1, min.Length).ToArray(); 138 | 139 | bool[] match_flags = new bool[max.Length]; 140 | int matches = 0; 141 | for (int mi = 0; mi < min.Length; mi++) 142 | { 143 | char c1 = min[mi]; 144 | for (int xi = Math.Max(mi - range, 0), 145 | xn = Math.Min(mi + range + 1, max.Length); xi < xn; xi++) 146 | { 147 | if (!match_flags[xi] && c1 == max[xi]) 148 | { 149 | match_indexes[mi] = xi; 150 | match_flags[xi] = true; 151 | matches++; 152 | break; 153 | } 154 | } 155 | } 156 | char[] ms1 = new char[matches]; 157 | char[] ms2 = new char[matches]; 158 | for (int i = 0, si = 0; i < min.Length; i++) 159 | { 160 | if (match_indexes[i] != -1) 161 | { 162 | ms1[si] = min[i]; 163 | si++; 164 | } 165 | } 166 | for (int i = 0, si = 0; i < max.Length; i++) 167 | { 168 | if (match_flags[i]) 169 | { 170 | ms2[si] = max[i]; 171 | si++; 172 | } 173 | } 174 | int transpositions = 0; 175 | for (int mi = 0; mi < ms1.Length; mi++) 176 | { 177 | if (ms1[mi] != ms2[mi]) 178 | { 179 | transpositions++; 180 | } 181 | } 182 | int prefix = 0; 183 | for (int mi = 0; mi < min.Length; mi++) 184 | { 185 | if (s1[mi] == s2[mi]) 186 | { 187 | prefix++; 188 | } 189 | else 190 | { 191 | break; 192 | } 193 | } 194 | return new[] { matches, transpositions / 2, prefix, max.Length }; 195 | } 196 | } 197 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Core/Utils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace Jellyfin.Plugin.MetaShark.Core 8 | { 9 | public static class Utils 10 | { 11 | public static DateTime UnixTimeStampToDateTime(long unixTimeStamp) 12 | { 13 | // Unix timestamp is seconds past epoch 14 | DateTime dateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); 15 | dateTime = dateTime.AddSeconds(unixTimeStamp).ToLocalTime(); 16 | return dateTime; 17 | } 18 | 19 | /// 20 | /// 转换数字 21 | /// 22 | public static int? ChineseNumberToInt(string str) 23 | { 24 | if (string.IsNullOrEmpty(str)) return null; 25 | 26 | var chineseNumberMap = new Dictionary() { 27 | {'一', '1'}, 28 | {'二', '2'}, 29 | {'三', '3'}, 30 | {'四', '4'}, 31 | {'五', '5'}, 32 | {'六', '6'}, 33 | {'七', '7'}, 34 | {'八', '8'}, 35 | {'九', '9'}, 36 | {'零', '0'}, 37 | }; 38 | 39 | var numberArr = str.ToCharArray().Select(x => chineseNumberMap.ContainsKey(x) ? chineseNumberMap[x] : x).ToArray(); 40 | var newNumberStr = new string(numberArr); 41 | if (int.TryParse(new string(numberArr), out var number)) 42 | { 43 | return number; 44 | } 45 | 46 | return null; 47 | } 48 | 49 | /// 50 | /// 转换中文数字 51 | /// 52 | public static string? ToChineseNumber(int? number) 53 | { 54 | if (number is null) return null; 55 | 56 | var chineseNumberMap = new Dictionary() { 57 | {'1','一'}, 58 | {'2','二'}, 59 | {'3','三'}, 60 | {'4','四'}, 61 | {'5','五'}, 62 | {'6','六'}, 63 | {'7','七'}, 64 | {'8','八'}, 65 | {'9','九'}, 66 | {'0','零'}, 67 | }; 68 | 69 | var numberArr = $"{number}".ToCharArray().Select(x => chineseNumberMap.ContainsKey(x) ? chineseNumberMap[x] : x).ToArray(); 70 | return new string(numberArr); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/ILRepack.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 27 | 28 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Jellyfin.Plugin.MetaShark.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net8.0 4 | Jellyfin.Plugin.MetaShark 5 | False 6 | true 7 | enable 8 | AllEnabledByDefault 9 | true 10 | 11 | 12 | False 13 | 14 | 15 | False 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | runtime; build; native; contentfiles; analyzers; buildtransitive 26 | all 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Model/ApiResult.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Jellyfin.Plugin.MetaShark.Model; 4 | 5 | public class ApiResult 6 | { 7 | [JsonPropertyName("code")] 8 | public int Code { get; set; } 9 | [JsonPropertyName("msg")] 10 | public string Msg { get; set; } = string.Empty; 11 | 12 | public ApiResult(int code, string msg = "") 13 | { 14 | this.Code = code; 15 | this.Msg = msg; 16 | } 17 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Model/DoubanLoginInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Data; 4 | using System.Linq; 5 | using System.Text.Json.Serialization; 6 | 7 | namespace Jellyfin.Plugin.MetaShark.Model 8 | { 9 | public class DoubanLoginInfo 10 | { 11 | public string Name { get; set; } 12 | public bool IsLogined { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Model/DoubanSubject.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Data; 4 | using System.Linq; 5 | using System.Text.Json.Serialization; 6 | 7 | namespace Jellyfin.Plugin.MetaShark.Model 8 | { 9 | public class DoubanSubject 10 | { 11 | // "name": "哈利·波特与魔法石", 12 | public string Name { get; set; } 13 | // "originalName": "Harry Potter and the Sorcerer's Stone", 14 | public string OriginalName { get; set; } 15 | // "rating": "9.1", 16 | public float Rating { get; set; } 17 | // "img": "https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2614949805.webp", 18 | public string Img { get; set; } 19 | // "sid": "1295038", 20 | public string Sid { get; set; } 21 | // "year": "2001", 22 | public int Year { get; set; } 23 | // "director": "克里斯·哥伦布", 24 | public string Director { get; set; } 25 | // "writer": "史蒂夫·克洛夫斯 / J·K·罗琳", 26 | public string Writer { get; set; } 27 | // "actor": "丹尼尔·雷德克里夫 / 艾玛·沃森 / 鲁伯特·格林特 / 艾伦·瑞克曼 / 玛吉·史密斯 / 更多...", 28 | public string Actor { get; set; } 29 | // "genre": "奇幻 / 冒险", 30 | public string Genre { get; set; } 31 | // 电影/电视剧 32 | public string Category { get; set; } 33 | // "site": "www.harrypotter.co.uk", 34 | public string Site { get; set; } 35 | // "country": "美国 / 英国", 36 | public string Country { get; set; } 37 | // "language": "英语", 38 | public string Language { get; set; } 39 | // "screen": "2002-01-26(中国大陆) / 2020-08-14(中国大陆重映) / 2001-11-04(英国首映) / 2001-11-16(美国)", 40 | public string Screen { get; set; } 41 | public DateTime? ScreenTime 42 | { 43 | get 44 | { 45 | if (Screen == null) return null; 46 | 47 | var items = Screen.Split("/"); 48 | if (items.Length >= 0) 49 | { 50 | var item = items[0].Split("(")[0]; 51 | DateTime result; 52 | DateTime.TryParseExact(item, "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out result); 53 | return result; 54 | } 55 | return null; 56 | } 57 | } 58 | // "duration": "152分钟 / 159分钟(加长版)", 59 | public string Duration { get; set; } 60 | // "subname": "哈利波特1:神秘的魔法石(港/台) / 哈1 / Harry Potter and the Philosopher's Stone", 61 | public string Subname { get; set; } 62 | // "imdb": "tt0241527" 63 | public string Imdb { get; set; } 64 | public string Intro { get; set; } 65 | 66 | public List Celebrities { get; set; } 67 | 68 | [JsonIgnore] 69 | public List LimitDirectorCelebrities 70 | { 71 | get 72 | { 73 | // 限制导演最多返回5个 74 | var limitCelebrities = new List(); 75 | if (Celebrities == null || Celebrities.Count == 0) 76 | { 77 | return limitCelebrities; 78 | } 79 | 80 | limitCelebrities.AddRange(Celebrities.Where(x => x.RoleType == MediaBrowser.Model.Entities.PersonType.Director && !string.IsNullOrEmpty(x.Name)).Take(5)); 81 | limitCelebrities.AddRange(Celebrities.Where(x => x.RoleType != MediaBrowser.Model.Entities.PersonType.Director && !string.IsNullOrEmpty(x.Name))); 82 | 83 | return limitCelebrities; 84 | } 85 | } 86 | 87 | [JsonIgnore] 88 | public string ImgMiddle 89 | { 90 | get 91 | { 92 | return this.Img.Replace("s_ratio_poster", "m"); 93 | } 94 | } 95 | 96 | [JsonIgnore] 97 | public string ImgLarge 98 | { 99 | get 100 | { 101 | return this.Img.Replace("s_ratio_poster", "l"); 102 | } 103 | } 104 | 105 | [JsonIgnore] 106 | public string[] Genres 107 | { 108 | get 109 | { 110 | return this.Genre.Split("/").Select(x => x.Trim()).Where(x => !string.IsNullOrEmpty(x)).ToArray(); 111 | } 112 | } 113 | 114 | 115 | [JsonIgnore] 116 | public string PrimaryLanguageCode 117 | { 118 | get 119 | { 120 | var languageCodeMap = new Dictionary() { 121 | { "日语", "ja" }, 122 | { "法语", "fr" }, 123 | { "德语", "de" }, 124 | { "俄语", "ru" }, 125 | { "韩语", "ko" }, 126 | { "泰语", "th" }, 127 | { "泰米尔语", "ta" }, 128 | }; 129 | var primaryLanguage = this.Language.Split("/").Select(x => x.Trim()).Where(x => !string.IsNullOrEmpty(x)).FirstOrDefault(); 130 | if (!string.IsNullOrEmpty(primaryLanguage)) 131 | { 132 | if (languageCodeMap.TryGetValue(primaryLanguage, out var lang)) 133 | { 134 | return lang; 135 | } 136 | } 137 | 138 | return string.Empty; 139 | } 140 | } 141 | } 142 | 143 | public class DoubanCelebrity 144 | { 145 | public string Id { get; set; } 146 | public string Name { get; set; } 147 | public string Img { get; set; } 148 | public string Role { get; set; } 149 | 150 | public string Intro { get; set; } 151 | public string Gender { get; set; } 152 | public string Constellation { get; set; } 153 | public string Birthdate { get; set; } 154 | public string Enddate { get; set; } 155 | public string Birthplace { get; set; } 156 | public string NickName { get; set; } 157 | public string EnglishName { get; set; } 158 | public string Imdb { get; set; } 159 | public string Site { get; set; } 160 | 161 | private string _roleType; 162 | public string RoleType 163 | { 164 | get 165 | { 166 | if (string.IsNullOrEmpty(this._roleType)) 167 | { 168 | return this.Role.Contains("导演", StringComparison.Ordinal) ? MediaBrowser.Model.Entities.PersonType.Director : MediaBrowser.Model.Entities.PersonType.Actor; 169 | } 170 | 171 | return this._roleType.Contains("导演", StringComparison.Ordinal) ? MediaBrowser.Model.Entities.PersonType.Director : MediaBrowser.Model.Entities.PersonType.Actor; 172 | } 173 | set 174 | { 175 | _roleType = value; 176 | } 177 | } 178 | 179 | public string? DisplayOriginalName 180 | { 181 | get 182 | { 183 | // 外国人才显示英文名 184 | if (Name.Contains("·") && Birthplace != null && !Birthplace.Contains("中国")) 185 | { 186 | return EnglishName; 187 | } 188 | 189 | return null; 190 | } 191 | } 192 | 193 | [JsonIgnore] 194 | public string ImgMiddle 195 | { 196 | get 197 | { 198 | return this.Img.Replace("/raw/", "/m/").Replace("/s_ratio_poster/", "/m/"); 199 | } 200 | } 201 | 202 | } 203 | 204 | public class DoubanPhoto 205 | { 206 | public string Id { get; set; } 207 | public string Small { get; set; } 208 | public string Medium { get; set; } 209 | public string Large { get; set; } 210 | /// 211 | /// 原始图片url,必须带referer访问 212 | /// 213 | public string Raw { get; set; } 214 | public string Size { get; set; } 215 | public int? Width { get; set; } 216 | public int? Height { get; set; } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Model/DoubanSuggest.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using System.Text.RegularExpressions; 3 | using Jellyfin.Plugin.MetaShark.Core; 4 | 5 | namespace Jellyfin.Plugin.MetaShark.Model; 6 | 7 | public class DoubanSuggest 8 | { 9 | [JsonPropertyName("title")] 10 | public string Title { get; set; } = string.Empty; 11 | [JsonPropertyName("url")] 12 | public string Url { get; set; } = string.Empty; 13 | [JsonPropertyName("year")] 14 | public string Year { get; set; } = string.Empty; 15 | [JsonPropertyName("type")] 16 | public string Type { get; set; } = string.Empty; 17 | 18 | 19 | public string Sid 20 | { 21 | get 22 | { 23 | var regSid = new Regex(@"subject\/(\d+?)\/", RegexOptions.Compiled); 24 | return this.Url.GetMatchGroup(regSid); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Model/DoubanSuggestResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Jellyfin.Plugin.MetaShark.Model; 5 | 6 | public class DoubanSuggestResult 7 | { 8 | [JsonPropertyName("cards")] 9 | public List? Cards { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Model/GuessInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace Jellyfin.Plugin.MetaShark.Model 8 | { 9 | public class GuessInfo 10 | { 11 | public int? episodeNumber { get; set; } 12 | 13 | public int? seasonNumber { get; set; } 14 | 15 | public string? Name { get; set; } 16 | } 17 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Model/MetaSource.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace Jellyfin.Plugin.MetaShark.Model 8 | { 9 | 10 | public enum MetaSource 11 | { 12 | Douban, 13 | Tmdb, 14 | None 15 | } 16 | 17 | public static class MetaSourceExtensions 18 | { 19 | public static MetaSource ToMetaSource(this string? str) 20 | { 21 | if (str == null) 22 | { 23 | return MetaSource.None; 24 | } 25 | 26 | if (str.ToLower().StartsWith("douban")) 27 | { 28 | return MetaSource.Douban; 29 | } 30 | 31 | if (str.ToLower().StartsWith("tmdb")) 32 | { 33 | return MetaSource.Tmdb; 34 | } 35 | 36 | return MetaSource.None; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Model/OmdbItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Text.Json.Serialization; 6 | using System.Threading.Tasks; 7 | 8 | namespace Jellyfin.Plugin.MetaShark.Model 9 | { 10 | public class OmdbItem 11 | { 12 | [JsonPropertyName("imdbID")] 13 | public string ImdbID { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Model/ParseNameResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Specialized; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Text.Json.Serialization; 7 | using System.Threading.Tasks; 8 | using MediaBrowser.Controller.Providers; 9 | 10 | namespace Jellyfin.Plugin.MetaShark.Model 11 | { 12 | public class ParseNameResult : ItemLookupInfo 13 | { 14 | public string? ChineseName { get; set; } = null; 15 | 16 | /// 17 | /// 可能会解析不对,最好只在动画SP中才使用 18 | /// 19 | public string? EpisodeName { get; set; } = null; 20 | 21 | private string _animeType = string.Empty; 22 | public string AnimeType 23 | { 24 | get 25 | { 26 | return _animeType.ToUpper(); 27 | } 28 | set 29 | { 30 | _animeType = value; 31 | } 32 | } 33 | 34 | public bool IsSpecial 35 | { 36 | get 37 | { 38 | return !string.IsNullOrEmpty(AnimeType) && AnimeType.ToUpper() == "SP"; 39 | } 40 | } 41 | 42 | public bool IsExtra 43 | { 44 | get 45 | { 46 | return !string.IsNullOrEmpty(AnimeType) && AnimeType.ToUpper() != "SP"; 47 | } 48 | } 49 | 50 | public string? PaddingZeroIndexNumber 51 | { 52 | get 53 | { 54 | if (!IndexNumber.HasValue) 55 | { 56 | return null; 57 | } 58 | 59 | return $"{IndexNumber:00}"; 60 | } 61 | } 62 | 63 | public string ExtraName 64 | { 65 | get 66 | { 67 | if (IndexNumber.HasValue) 68 | { 69 | return $"{AnimeType} {PaddingZeroIndexNumber}"; 70 | } 71 | else 72 | { 73 | return $"{AnimeType}"; 74 | } 75 | } 76 | } 77 | 78 | public string SpecialName 79 | { 80 | get 81 | { 82 | if (!string.IsNullOrEmpty(EpisodeName) && IndexNumber.HasValue) 83 | { 84 | return $"{EpisodeName} {IndexNumber}"; 85 | } 86 | else if (!string.IsNullOrEmpty(EpisodeName)) 87 | { 88 | return EpisodeName; 89 | } 90 | 91 | return Name; 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Plugin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using Jellyfin.Plugin.MetaShark.Configuration; 5 | using MediaBrowser.Common.Configuration; 6 | using MediaBrowser.Common.Plugins; 7 | using MediaBrowser.Controller; 8 | using MediaBrowser.Model.Plugins; 9 | using MediaBrowser.Model.Serialization; 10 | using Microsoft.AspNetCore.Http; 11 | 12 | namespace Jellyfin.Plugin.MetaShark; 13 | 14 | /// 15 | /// The main plugin. 16 | /// 17 | public class Plugin : BasePlugin, IHasWebPages 18 | { 19 | /// 20 | /// Gets the provider name. 21 | /// 22 | public const string PluginName = "MetaShark"; 23 | 24 | /// 25 | /// Gets the provider id. 26 | /// 27 | public const string ProviderId = "MetaSharkID"; 28 | 29 | 30 | private readonly IServerApplicationHost _appHost; 31 | 32 | /// 33 | /// Initializes a new instance of the class. 34 | /// 35 | /// Instance of the interface. 36 | /// Instance of the interface. 37 | public Plugin(IServerApplicationHost appHost, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) 38 | : base(applicationPaths, xmlSerializer) 39 | { 40 | this._appHost = appHost; 41 | Plugin.Instance = this; 42 | } 43 | 44 | /// 45 | public override string Name => PluginName; 46 | 47 | /// 48 | public override Guid Id => Guid.Parse("9A19103F-16F7-4668-BE54-9A1E7A4F7556"); 49 | 50 | /// 51 | /// Gets the current plugin instance. 52 | /// 53 | public static Plugin? Instance { get; private set; } 54 | 55 | /// 56 | public IEnumerable GetPages() 57 | { 58 | return new[] 59 | { 60 | new PluginPageInfo 61 | { 62 | Name = this.Name, 63 | EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace), 64 | }, 65 | }; 66 | } 67 | 68 | public string GetLocalApiBaseUrl() 69 | { 70 | return this._appHost.GetLocalApiUrl("127.0.0.1", "http"); 71 | } 72 | 73 | public string GetApiBaseUrl(HttpRequest request) 74 | { 75 | int? requestPort = request.Host.Port; 76 | if (requestPort == null 77 | || (requestPort == 80 && string.Equals(request.Scheme, "http", StringComparison.OrdinalIgnoreCase)) 78 | || (requestPort == 443 && string.Equals(request.Scheme, "https", StringComparison.OrdinalIgnoreCase))) 79 | { 80 | requestPort = -1; 81 | } 82 | 83 | return this._appHost.GetLocalApiUrl(request.Host.Host, request.Scheme, requestPort); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/BoxSetImageProvider.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using MediaBrowser.Controller.Entities; 3 | using MediaBrowser.Controller.Entities.Movies; 4 | using MediaBrowser.Controller.Library; 5 | using MediaBrowser.Controller.Providers; 6 | using MediaBrowser.Model.Dto; 7 | using MediaBrowser.Model.Entities; 8 | using MediaBrowser.Model.Extensions; 9 | using MediaBrowser.Model.Providers; 10 | using Microsoft.AspNetCore.Http; 11 | using Microsoft.Extensions.Logging; 12 | using System; 13 | using System.Collections.Generic; 14 | using System.Globalization; 15 | using System.Linq; 16 | using System.Net.Http; 17 | using System.Threading; 18 | using System.Threading.Tasks; 19 | 20 | namespace Jellyfin.Plugin.MetaShark.Providers 21 | { 22 | /// 23 | /// BoxSet image provider powered by TMDb. 24 | /// 25 | public class BoxSetImageProvider : BaseProvider, IRemoteImageProvider 26 | { 27 | public BoxSetImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi) 28 | : base(httpClientFactory, loggerFactory.CreateLogger(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi) 29 | { 30 | } 31 | 32 | /// 33 | public string Name => Plugin.PluginName; 34 | 35 | /// 36 | public bool Supports(BaseItem item) 37 | { 38 | return item is BoxSet; 39 | } 40 | 41 | /// 42 | public IEnumerable GetSupportedImages(BaseItem item) => 43 | [ 44 | ImageType.Primary, 45 | ImageType.Backdrop, 46 | ImageType.Thumb 47 | ]; 48 | 49 | /// 50 | public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) 51 | { 52 | var tmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); 53 | this.Log($"GetBoxSetImages of [name]: {item.Name} [tmdbId]: {tmdbId}"); 54 | 55 | if (tmdbId <= 0) 56 | { 57 | return Enumerable.Empty(); 58 | } 59 | 60 | var language = item.GetPreferredMetadataLanguage(); 61 | 62 | // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here 63 | var collection = await this._tmdbApi.GetCollectionAsync(tmdbId, null, null, cancellationToken).ConfigureAwait(false); 64 | 65 | if (collection?.Images is null) 66 | { 67 | return Enumerable.Empty(); 68 | } 69 | 70 | var posters = collection.Images.Posters; 71 | var backdrops = collection.Images.Backdrops; 72 | var remoteImages = new List(posters.Count + backdrops.Count); 73 | remoteImages.AddRange(posters.Select(x => new RemoteImageInfo { 74 | ProviderName = this.Name, 75 | Url = this._tmdbApi.GetPosterUrl(x.FilePath), 76 | Type = ImageType.Primary, 77 | CommunityRating = x.VoteAverage, 78 | VoteCount = x.VoteCount, 79 | Width = x.Width, 80 | Height = x.Height, 81 | Language = this.AdjustImageLanguage(x.Iso_639_1, language), 82 | RatingType = RatingType.Score, 83 | })); 84 | 85 | remoteImages.AddRange(backdrops.Select(x => new RemoteImageInfo { 86 | ProviderName = this.Name, 87 | Url = this._tmdbApi.GetBackdropUrl(x.FilePath), 88 | Type = ImageType.Backdrop, 89 | CommunityRating = x.VoteAverage, 90 | VoteCount = x.VoteCount, 91 | Width = x.Width, 92 | Height = x.Height, 93 | Language = this.AdjustImageLanguage(x.Iso_639_1, language), 94 | RatingType = RatingType.Score, 95 | })); 96 | 97 | return remoteImages.OrderByLanguageDescending(language); 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/BoxSetProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Jellyfin.Data.Enums; 9 | using Jellyfin.Plugin.MetaShark.Api; 10 | using MediaBrowser.Controller.Entities; 11 | using MediaBrowser.Controller.Entities.Movies; 12 | using MediaBrowser.Controller.Library; 13 | using MediaBrowser.Controller.Providers; 14 | using MediaBrowser.Model.Entities; 15 | using MediaBrowser.Model.Providers; 16 | using Microsoft.AspNetCore.Http; 17 | using Microsoft.Extensions.Logging; 18 | 19 | namespace Jellyfin.Plugin.MetaShark.Providers 20 | { 21 | /// 22 | /// BoxSet provider powered by TMDb. 23 | /// 24 | public class BoxSetProvider : BaseProvider, IRemoteMetadataProvider 25 | { 26 | public BoxSetProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi) 27 | : base(httpClientFactory, loggerFactory.CreateLogger(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi) 28 | { 29 | } 30 | 31 | public string Name => Plugin.PluginName; 32 | 33 | /// 34 | public async Task> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) 35 | { 36 | var tmdbId = Convert.ToInt32(searchInfo.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); 37 | var language = searchInfo.MetadataLanguage; 38 | 39 | if (tmdbId > 0) 40 | { 41 | var collection = await _tmdbApi.GetCollectionAsync(tmdbId, language, language, cancellationToken).ConfigureAwait(false); 42 | 43 | if (collection is null) 44 | { 45 | return Enumerable.Empty(); 46 | } 47 | 48 | var result = new RemoteSearchResult 49 | { 50 | Name = collection.Name, 51 | SearchProviderName = Name 52 | }; 53 | 54 | if (collection.Images is not null) 55 | { 56 | result.ImageUrl = _tmdbApi.GetPosterUrl(collection.PosterPath); 57 | } 58 | 59 | result.SetProviderId(MetadataProvider.Tmdb, collection.Id.ToString(CultureInfo.InvariantCulture)); 60 | 61 | return new[] { result }; 62 | } 63 | 64 | var collectionSearchResults = await _tmdbApi.SearchCollectionAsync(searchInfo.Name, language, cancellationToken).ConfigureAwait(false); 65 | 66 | var collections = new RemoteSearchResult[collectionSearchResults.Count]; 67 | for (var i = 0; i < collectionSearchResults.Count; i++) 68 | { 69 | var result = collectionSearchResults[i]; 70 | var collection = new RemoteSearchResult 71 | { 72 | Name = result.Name, 73 | SearchProviderName = Name, 74 | ImageUrl = _tmdbApi.GetPosterUrl(result.PosterPath) 75 | }; 76 | collection.SetProviderId(MetadataProvider.Tmdb, result.Id.ToString(CultureInfo.InvariantCulture)); 77 | 78 | collections[i] = collection; 79 | } 80 | 81 | return collections; 82 | } 83 | 84 | /// 85 | public async Task> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) 86 | { 87 | var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); 88 | var language = info.MetadataLanguage; 89 | this.Log($"GetBoxSetMetadata of [name]: {info.Name} [tmdbId]: {tmdbId} EnableTmdb: {config.EnableTmdb}"); 90 | 91 | // We don't already have an Id, need to fetch it 92 | if (tmdbId <= 0) 93 | { 94 | // ParseName is required here. 95 | // Caller provides the filename with extension stripped and NOT the parsed filename 96 | var parsedName = _libraryManager.ParseName(info.Name); 97 | var searchResults = await _tmdbApi.SearchCollectionAsync(parsedName.Name, language, cancellationToken).ConfigureAwait(false); 98 | 99 | if (searchResults is not null && searchResults.Count > 0) 100 | { 101 | tmdbId = searchResults.FirstOrDefault(x => x.Name == info.Name)?.Id ?? 0; 102 | if (tmdbId <= 0) 103 | { 104 | tmdbId = searchResults[0].Id; 105 | } 106 | } 107 | } 108 | 109 | var result = new MetadataResult(); 110 | 111 | if (tmdbId > 0) 112 | { 113 | var collection = await _tmdbApi.GetCollectionAsync(tmdbId, language, language, cancellationToken).ConfigureAwait(false); 114 | 115 | if (collection is not null) 116 | { 117 | var item = new BoxSet 118 | { 119 | Name = collection.Name, 120 | Overview = collection.Overview, 121 | }; 122 | 123 | var oldBotSet = _libraryManager.GetItemList(new InternalItemsQuery 124 | { 125 | IncludeItemTypes = new[] { BaseItemKind.BoxSet }, 126 | CollapseBoxSetItems = false, 127 | Recursive = true 128 | }).Select(b => b as BoxSet).FirstOrDefault(x => x.Name == collection.Name); 129 | if (oldBotSet != null) 130 | { 131 | item.LinkedChildren = oldBotSet.LinkedChildren; 132 | } 133 | item.SetProviderId(MetadataProvider.Tmdb, collection.Id.ToString(CultureInfo.InvariantCulture)); 134 | 135 | result.HasMetadata = true; 136 | result.Item = item; 137 | } 138 | } 139 | 140 | return result; 141 | } 142 | 143 | } 144 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/EpisodeImageProvider.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using MediaBrowser.Controller.Entities; 3 | using MediaBrowser.Controller.Entities.TV; 4 | using MediaBrowser.Controller.Library; 5 | using MediaBrowser.Controller.Providers; 6 | using MediaBrowser.Model.Entities; 7 | using MediaBrowser.Model.Providers; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.Extensions.Logging; 10 | using System; 11 | using System.Collections.Generic; 12 | using System.Globalization; 13 | using System.Linq; 14 | using System.Net.Http; 15 | using System.Threading; 16 | using System.Threading.Tasks; 17 | 18 | namespace Jellyfin.Plugin.MetaShark.Providers 19 | { 20 | public class EpisodeImageProvider : BaseProvider, IRemoteImageProvider 21 | { 22 | public EpisodeImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi) 23 | : base(httpClientFactory, loggerFactory.CreateLogger(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi) 24 | { 25 | } 26 | 27 | /// 28 | public string Name => Plugin.PluginName; 29 | 30 | /// 31 | public bool Supports(BaseItem item) => item is Episode; 32 | 33 | /// 34 | public IEnumerable GetSupportedImages(BaseItem item) 35 | { 36 | yield return ImageType.Primary; 37 | } 38 | 39 | /// 40 | public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) 41 | { 42 | this.Log($"GetEpisodeImages of [name]: {item.Name} number: {item.IndexNumber} ParentIndexNumber: {item.ParentIndexNumber}"); 43 | 44 | var episode = (MediaBrowser.Controller.Entities.TV.Episode)item; 45 | var series = episode.Series; 46 | 47 | var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); 48 | 49 | if (seriesTmdbId <= 0) 50 | { 51 | this.Log($"[GetEpisodeImages] The seriesTmdbId is empty!"); 52 | return Enumerable.Empty(); 53 | } 54 | 55 | var seasonNumber = episode.ParentIndexNumber; 56 | var episodeNumber = episode.IndexNumber; 57 | 58 | if (seasonNumber is null or 0 || episodeNumber is null or 0) 59 | { 60 | this.Log($"[GetEpisodeImages] The seasonNumber or episodeNumber is empty! seasonNumber: {seasonNumber} episodeNumber: {episodeNumber}"); 61 | return Enumerable.Empty(); 62 | } 63 | var language = item.GetPreferredMetadataLanguage(); 64 | 65 | // 利用season缓存取剧集信息会更快 66 | var seasonResult = await this._tmdbApi 67 | .GetSeasonAsync(seriesTmdbId, seasonNumber.Value, null, null, cancellationToken) 68 | .ConfigureAwait(false); 69 | if (seasonResult == null || seasonResult.Episodes.Count < episodeNumber.Value) 70 | { 71 | this.Log($"[GetEpisodeImages] Can't get season data for seasonNumber: {seasonNumber} episodeNumber: {episodeNumber}"); 72 | return Enumerable.Empty(); 73 | } 74 | 75 | var result = new List(); 76 | var episodeResult = seasonResult.Episodes[episodeNumber.Value - 1]; 77 | if (!string.IsNullOrEmpty(episodeResult.StillPath)) 78 | { 79 | result.Add(new RemoteImageInfo 80 | { 81 | Url = this._tmdbApi.GetStillUrl(episodeResult.StillPath), 82 | CommunityRating = episodeResult.VoteAverage, 83 | VoteCount = episodeResult.VoteCount, 84 | ProviderName = Name, 85 | Type = ImageType.Primary, 86 | }); 87 | } 88 | return result; 89 | } 90 | 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/Extensions/EnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using MediaBrowser.Model.Providers; 5 | 6 | namespace Jellyfin.Plugin.MetaShark.Providers 7 | { 8 | public static class EnumerableExtensions 9 | { 10 | private const int MaxPriority = 99; 11 | 12 | public static IEnumerable OrderByLanguageDescending(this IEnumerable remoteImageInfos, params string[] requestedLanguages) 13 | { 14 | if (requestedLanguages.Length <= 0) 15 | { 16 | requestedLanguages = new[] { "en" }; 17 | } 18 | 19 | var requestedLanguagePriorityMap = new Dictionary(); 20 | for (int i = 0; i < requestedLanguages.Length; i++) 21 | { 22 | if (string.IsNullOrEmpty(requestedLanguages[i])) 23 | { 24 | continue; 25 | } 26 | requestedLanguagePriorityMap.Add(NormalizeLanguage(requestedLanguages[i]), MaxPriority - i); 27 | } 28 | 29 | return remoteImageInfos.OrderByDescending(delegate (RemoteImageInfo i) 30 | { 31 | if (string.IsNullOrEmpty(i.Language)) 32 | { 33 | return 3; 34 | } 35 | 36 | if (requestedLanguagePriorityMap.TryGetValue(NormalizeLanguage(i.Language), out int priority)) 37 | { 38 | return priority; 39 | } 40 | 41 | return string.Equals(i.Language, "en", StringComparison.OrdinalIgnoreCase) ? 2 : 0; 42 | }).ThenByDescending((RemoteImageInfo i) => i.CommunityRating.GetValueOrDefault()).ThenByDescending((RemoteImageInfo i) => i.VoteCount.GetValueOrDefault()); 43 | } 44 | 45 | private static string NormalizeLanguage(string language) 46 | { 47 | if (string.IsNullOrEmpty(language)) 48 | { 49 | return language; 50 | } 51 | 52 | return language.Split('-')[0].ToLower(); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/Extensions/ProviderIdsExtensions.cs: -------------------------------------------------------------------------------- 1 | 2 | using System.Collections.Generic; 3 | using Jellyfin.Plugin.MetaShark.Model; 4 | using MediaBrowser.Model.Entities; 5 | 6 | namespace Jellyfin.Plugin.MetaShark.Providers 7 | { 8 | public static class ProviderIdsExtensions 9 | { 10 | public static MetaSource GetMetaSource(this IHasProviderIds instance, string name) 11 | { 12 | var value = instance.GetProviderId(name); 13 | return value.ToMetaSource(); 14 | } 15 | 16 | public static void TryGetMetaSource(this Dictionary dict, string name, out MetaSource metaSource) 17 | { 18 | if (dict.TryGetValue(name, out var value)) 19 | { 20 | metaSource = value.ToMetaSource(); 21 | } 22 | else 23 | { 24 | metaSource = MetaSource.None; 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/ExternalId/DoubanExternalId.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Entities.Movies; 2 | using MediaBrowser.Controller.Entities.TV; 3 | using MediaBrowser.Controller.Providers; 4 | using MediaBrowser.Model.Entities; 5 | using MediaBrowser.Model.Providers; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Text; 10 | using System.Threading.Tasks; 11 | 12 | namespace Jellyfin.Plugin.MetaShark.Providers.ExternalId 13 | { 14 | public class DoubanExternalId : IExternalId 15 | { 16 | public string ProviderName => BaseProvider.DoubanProviderName; 17 | 18 | public string Key => BaseProvider.DoubanProviderId; 19 | 20 | public ExternalIdMediaType? Type => null; 21 | 22 | public string UrlFormatString => "https://movie.douban.com/subject/{0}/"; 23 | 24 | public bool Supports(IHasProviderIds item) 25 | { 26 | return item is Movie || item is Series || item is Season; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/ExternalId/DoubanPersonExternalId.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Entities; 2 | using MediaBrowser.Controller.Providers; 3 | using MediaBrowser.Model.Entities; 4 | using MediaBrowser.Model.Providers; 5 | 6 | namespace Jellyfin.Plugin.MetaShark.Providers.ExternalId 7 | { 8 | /// 9 | public class DoubanPersonExternalId : IExternalId 10 | { 11 | /// 12 | public string ProviderName => BaseProvider.DoubanProviderName; 13 | 14 | /// 15 | public string Key => BaseProvider.DoubanProviderId; 16 | 17 | /// 18 | public ExternalIdMediaType? Type => ExternalIdMediaType.Person; 19 | 20 | /// 21 | public string UrlFormatString => "https://www.douban.com/personage/{0}/"; 22 | 23 | /// 24 | public bool Supports(IHasProviderIds item) => item is Person; 25 | } 26 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/MovieImageProvider.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Jellyfin.Plugin.MetaShark.Model; 4 | using MediaBrowser.Controller.Entities; 5 | using MediaBrowser.Controller.Entities.Movies; 6 | using MediaBrowser.Controller.Library; 7 | using MediaBrowser.Controller.Providers; 8 | using MediaBrowser.Model.Dto; 9 | using MediaBrowser.Model.Entities; 10 | using MediaBrowser.Model.Providers; 11 | using Microsoft.AspNetCore.Http; 12 | using Microsoft.Extensions.Logging; 13 | using System.Collections.Generic; 14 | using System.Linq; 15 | using System.Net.Http; 16 | using System.Threading; 17 | using System.Threading.Tasks; 18 | 19 | namespace Jellyfin.Plugin.MetaShark.Providers 20 | { 21 | public class MovieImageProvider : BaseProvider, IRemoteImageProvider 22 | { 23 | public MovieImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi) 24 | : base(httpClientFactory, loggerFactory.CreateLogger(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi) 25 | { 26 | } 27 | 28 | /// 29 | public string Name => Plugin.PluginName; 30 | 31 | /// 32 | public bool Supports(BaseItem item) => item is Movie; 33 | 34 | /// 35 | public IEnumerable GetSupportedImages(BaseItem item) => new List 36 | { 37 | ImageType.Primary, 38 | ImageType.Backdrop, 39 | ImageType.Logo, 40 | }; 41 | 42 | /// 43 | public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) 44 | { 45 | var sid = item.GetProviderId(DoubanProviderId); 46 | var metaSource = item.GetMetaSource(Plugin.ProviderId); 47 | this.Log($"GetImages for item: {item.Name} [metaSource]: {metaSource}"); 48 | if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid)) 49 | { 50 | var primary = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false); 51 | if (primary == null || string.IsNullOrEmpty(primary.Img)) 52 | { 53 | return Enumerable.Empty(); 54 | } 55 | var backdropImgs = await this.GetBackdrop(item, primary.PrimaryLanguageCode, cancellationToken).ConfigureAwait(false); 56 | var logoImgs = await this.GetLogos(item, primary.PrimaryLanguageCode, cancellationToken).ConfigureAwait(false); 57 | 58 | var res = new List { 59 | new RemoteImageInfo 60 | { 61 | ProviderName = this.Name, 62 | Url = this.GetDoubanPoster(primary), 63 | Type = ImageType.Primary, 64 | Language = "zh", 65 | }, 66 | }; 67 | res.AddRange(backdropImgs); 68 | res.AddRange(logoImgs); 69 | return res; 70 | } 71 | 72 | var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); 73 | if (metaSource == MetaSource.Tmdb && !string.IsNullOrEmpty(tmdbId)) 74 | { 75 | var language = item.GetPreferredMetadataLanguage(); 76 | // 设定language会导致图片被过滤,这里设为null,保持取全部语言图片 77 | var movie = await this._tmdbApi 78 | .GetMovieAsync(tmdbId.ToInt(), null, null, cancellationToken) 79 | .ConfigureAwait(false); 80 | 81 | if (movie?.Images == null) 82 | { 83 | return Enumerable.Empty(); 84 | } 85 | 86 | var remoteImages = new List(); 87 | 88 | remoteImages.AddRange(movie.Images.Posters.Select(x => new RemoteImageInfo { 89 | ProviderName = this.Name, 90 | Url = this._tmdbApi.GetPosterUrl(x.FilePath), 91 | Type = ImageType.Primary, 92 | CommunityRating = x.VoteAverage, 93 | VoteCount = x.VoteCount, 94 | Width = x.Width, 95 | Height = x.Height, 96 | Language = this.AdjustImageLanguage(x.Iso_639_1, language), 97 | RatingType = RatingType.Score, 98 | })); 99 | 100 | remoteImages.AddRange(movie.Images.Backdrops.Select(x => new RemoteImageInfo { 101 | ProviderName = this.Name, 102 | Url = this._tmdbApi.GetBackdropUrl(x.FilePath), 103 | Type = ImageType.Backdrop, 104 | CommunityRating = x.VoteAverage, 105 | VoteCount = x.VoteCount, 106 | Width = x.Width, 107 | Height = x.Height, 108 | Language = this.AdjustImageLanguage(x.Iso_639_1, language), 109 | RatingType = RatingType.Score, 110 | })); 111 | 112 | remoteImages.AddRange(movie.Images.Logos.Select(x => new RemoteImageInfo { 113 | ProviderName = this.Name, 114 | Url = this._tmdbApi.GetLogoUrl(x.FilePath), 115 | Type = ImageType.Logo, 116 | CommunityRating = x.VoteAverage, 117 | VoteCount = x.VoteCount, 118 | Width = x.Width, 119 | Height = x.Height, 120 | Language = this.AdjustImageLanguage(x.Iso_639_1, language), 121 | RatingType = RatingType.Score, 122 | })); 123 | 124 | return remoteImages.OrderByLanguageDescending(language); 125 | } 126 | 127 | this.Log($"Got images failed because the images of \"{item.Name}\" is empty!"); 128 | return new List(); 129 | } 130 | 131 | /// 132 | /// Query for a background photo 133 | /// 134 | /// Instance of the interface. 135 | private async Task> GetBackdrop(BaseItem item, string alternativeImageLanguage, CancellationToken cancellationToken) 136 | { 137 | var sid = item.GetProviderId(DoubanProviderId); 138 | var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); 139 | var list = new List(); 140 | 141 | // 从豆瓣获取背景图 142 | if (!string.IsNullOrEmpty(sid)) 143 | { 144 | var photo = await this._doubanApi.GetWallpaperBySidAsync(sid, cancellationToken).ConfigureAwait(false); 145 | if (photo != null && photo.Count > 0) 146 | { 147 | this.Log("GetBackdrop from douban sid: {0}", sid); 148 | list = photo.Where(x => x.Width >= 1280 && x.Width <= 4096 && x.Width > x.Height * 1.3).Select(x => 149 | { 150 | if (config.EnableDoubanBackdropRaw) 151 | { 152 | return new RemoteImageInfo 153 | { 154 | ProviderName = this.Name, 155 | Url = this.GetProxyImageUrl(x.Raw), 156 | Height = x.Height, 157 | Width = x.Width, 158 | Type = ImageType.Backdrop, 159 | Language = "zh", 160 | }; 161 | } 162 | else 163 | { 164 | return new RemoteImageInfo 165 | { 166 | ProviderName = this.Name, 167 | Url = this.GetProxyImageUrl(x.Large), 168 | Type = ImageType.Backdrop, 169 | Language = "zh", 170 | }; 171 | } 172 | }).ToList(); 173 | 174 | } 175 | } 176 | 177 | // 添加 TheMovieDb 背景图为备选 178 | if (config.EnableTmdbBackdrop && !string.IsNullOrEmpty(tmdbId)) 179 | { 180 | var language = item.GetPreferredMetadataLanguage(); 181 | var movie = await this._tmdbApi 182 | .GetMovieAsync(tmdbId.ToInt(), language, language, cancellationToken) 183 | .ConfigureAwait(false); 184 | 185 | if (movie != null && !string.IsNullOrEmpty(movie.BackdropPath)) 186 | { 187 | this.Log("GetBackdrop from tmdb id: {0} lang: {1}", tmdbId, language); 188 | list.Add(new RemoteImageInfo 189 | { 190 | ProviderName = this.Name, 191 | Url = this._tmdbApi.GetBackdropUrl(movie.BackdropPath), 192 | Type = ImageType.Backdrop, 193 | Language = language, 194 | }); 195 | } 196 | } 197 | 198 | return list; 199 | } 200 | 201 | private async Task> GetLogos(BaseItem item, string alternativeImageLanguage, CancellationToken cancellationToken) 202 | { 203 | var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); 204 | var list = new List(); 205 | var language = item.GetPreferredMetadataLanguage(); 206 | if (this.config.EnableTmdbLogo && !string.IsNullOrEmpty(tmdbId)) 207 | { 208 | this.Log("GetLogos from tmdb id: {0}", tmdbId); 209 | var movie = await this._tmdbApi 210 | .GetMovieAsync(tmdbId.ToInt(), null, null, cancellationToken) 211 | .ConfigureAwait(false); 212 | 213 | if (movie != null && movie.Images != null) 214 | { 215 | list.AddRange(movie.Images.Logos.Select(x => new RemoteImageInfo { 216 | ProviderName = this.Name, 217 | Url = this._tmdbApi.GetLogoUrl(x.FilePath), 218 | Type = ImageType.Logo, 219 | CommunityRating = x.VoteAverage, 220 | VoteCount = x.VoteCount, 221 | Width = x.Width, 222 | Height = x.Height, 223 | Language = this.AdjustImageLanguage(x.Iso_639_1, language), 224 | RatingType = RatingType.Score, 225 | })); 226 | } 227 | } 228 | 229 | // TODO:jellyfin 内部判断取哪个图片时,还会默认使用 OrderByLanguageDescending 排序一次,这里排序没用 230 | // 默认图片优先级是:默认语言 > 无语言 > en > 其他语言 231 | return this.AdjustImageLanguagePriority(list, language, alternativeImageLanguage); 232 | } 233 | 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/PersonImageProvider.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using MediaBrowser.Controller.Entities; 3 | using MediaBrowser.Controller.Library; 4 | using MediaBrowser.Controller.Providers; 5 | using MediaBrowser.Model.Entities; 6 | using MediaBrowser.Model.Providers; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.Extensions.Logging; 9 | using System.Collections.Generic; 10 | using System.Net.Http; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | 14 | namespace Jellyfin.Plugin.MetaShark.Providers 15 | { 16 | public class PersonImageProvider : BaseProvider, IRemoteImageProvider 17 | { 18 | public PersonImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi) 19 | : base(httpClientFactory, loggerFactory.CreateLogger(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi) 20 | { 21 | } 22 | 23 | /// 24 | public string Name => Plugin.PluginName; 25 | 26 | /// 27 | public bool Supports(BaseItem item) => item is Person; 28 | 29 | /// 30 | public IEnumerable GetSupportedImages(BaseItem item) 31 | { 32 | yield return ImageType.Primary; 33 | } 34 | 35 | /// 36 | public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) 37 | { 38 | var list = new List(); 39 | var cid = item.GetProviderId(DoubanProviderId); 40 | var metaSource = item.GetMetaSource(Plugin.ProviderId); 41 | this.Log($"GetImages for item: {item.Name} [metaSource]: {metaSource}"); 42 | if (!string.IsNullOrEmpty(cid)) 43 | { 44 | var celebrity = await this._doubanApi.GetCelebrityAsync(cid, cancellationToken).ConfigureAwait(false); 45 | if (celebrity != null) 46 | { 47 | list.Add(new RemoteImageInfo 48 | { 49 | ProviderName = this.Name, 50 | Url = this.GetProxyImageUrl(celebrity.Img), 51 | Type = ImageType.Primary, 52 | Language = "zh", 53 | }); 54 | } 55 | 56 | var photos = await this._doubanApi.GetCelebrityPhotosAsync(cid, cancellationToken).ConfigureAwait(false); 57 | photos.ForEach(x => 58 | { 59 | // 过滤不是竖图 60 | if (x.Width < 400 || x.Height < x.Width * 1.3) 61 | { 62 | return; 63 | } 64 | 65 | list.Add(new RemoteImageInfo 66 | { 67 | ProviderName = this.Name, 68 | Url = this.GetProxyImageUrl(x.Raw), 69 | Width = x.Width, 70 | Height = x.Height, 71 | Type = ImageType.Primary, 72 | Language = "zh", 73 | }); 74 | }); 75 | } 76 | 77 | if (list.Count == 0) 78 | { 79 | this.Log($"Got images failed because the images of \"{item.Name}\" is empty!"); 80 | } 81 | return list; 82 | } 83 | 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/PersonProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.Extensions.Logging; 10 | using Jellyfin.Plugin.MetaShark.Api; 11 | using Jellyfin.Plugin.MetaShark.Core; 12 | using MediaBrowser.Controller.Entities; 13 | using MediaBrowser.Controller.Library; 14 | using MediaBrowser.Controller.Providers; 15 | using MediaBrowser.Model.Entities; 16 | using MediaBrowser.Model.Providers; 17 | using TMDbLib.Objects.Find; 18 | 19 | namespace Jellyfin.Plugin.MetaShark.Providers 20 | { 21 | /// 22 | /// OddbPersonProvider. 23 | /// 24 | public class PersonProvider : BaseProvider, IRemoteMetadataProvider 25 | { 26 | public PersonProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi) 27 | : base(httpClientFactory, loggerFactory.CreateLogger(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi) 28 | { 29 | } 30 | 31 | /// 32 | public string Name => Plugin.PluginName; 33 | 34 | /// 35 | public async Task> GetSearchResults(PersonLookupInfo searchInfo, CancellationToken cancellationToken) 36 | { 37 | this.Log($"GetPersonSearchResults of [name]: {searchInfo.Name}"); 38 | 39 | var result = new List(); 40 | var cid = searchInfo.GetProviderId(DoubanProviderId); 41 | if (!string.IsNullOrEmpty(cid)) 42 | { 43 | var celebrity = await this._doubanApi.GetCelebrityAsync(cid, cancellationToken).ConfigureAwait(false); 44 | if (celebrity != null) 45 | { 46 | result.Add(new RemoteSearchResult 47 | { 48 | SearchProviderName = DoubanProviderName, 49 | ProviderIds = new Dictionary { { DoubanProviderId, celebrity.Id } }, 50 | ImageUrl = this.GetProxyImageUrl(celebrity.Img), 51 | Name = celebrity.Name, 52 | } 53 | ); 54 | 55 | return result; 56 | } 57 | } 58 | 59 | 60 | 61 | var res = await this._doubanApi.SearchCelebrityAsync(searchInfo.Name, cancellationToken).ConfigureAwait(false); 62 | result.AddRange(res.Take(Configuration.PluginConfiguration.MAX_SEARCH_RESULT).Select(x => 63 | { 64 | return new RemoteSearchResult 65 | { 66 | SearchProviderName = DoubanProviderName, 67 | ProviderIds = new Dictionary { { DoubanProviderId, x.Id } }, 68 | ImageUrl = this.GetProxyImageUrl(x.Img), 69 | Name = x.Name, 70 | }; 71 | })); 72 | 73 | return result; 74 | } 75 | 76 | /// 77 | public async Task> GetMetadata(PersonLookupInfo info, CancellationToken cancellationToken) 78 | { 79 | var result = new MetadataResult(); 80 | 81 | var cid = info.GetProviderId(DoubanProviderId); 82 | this.Log($"GetPersonMetadata of [name]: {info.Name} [cid]: {cid}"); 83 | if (!string.IsNullOrEmpty(cid)) 84 | { 85 | 86 | var c = await this._doubanApi.GetCelebrityAsync(cid, cancellationToken).ConfigureAwait(false); 87 | if (c != null) 88 | { 89 | var item = new Person 90 | { 91 | // Name = c.Name.Trim(), // 名称需保持和info.Name一致,不然会导致关联不到影片,自动被删除 92 | OriginalTitle = c.DisplayOriginalName, // 外国人显示英文名 93 | HomePageUrl = c.Site, 94 | Overview = c.Intro, 95 | }; 96 | if (DateTime.TryParseExact(c.Birthdate, "yyyy年MM月dd日", null, DateTimeStyles.None, out var premiereDate)) 97 | { 98 | item.PremiereDate = premiereDate; 99 | item.ProductionYear = premiereDate.Year; 100 | } 101 | if (DateTime.TryParseExact(c.Enddate, "yyyy年MM月dd日", null, DateTimeStyles.None, out var endDate)) 102 | { 103 | item.EndDate = endDate; 104 | } 105 | if (!string.IsNullOrWhiteSpace(c.Birthplace)) 106 | { 107 | item.ProductionLocations = new[] { c.Birthplace }; 108 | } 109 | 110 | item.SetProviderId(DoubanProviderId, c.Id); 111 | if (!string.IsNullOrEmpty(c.Imdb)) 112 | { 113 | var newImdbId = await this._imdbApi.CheckPersonNewIDAsync(c.Imdb, cancellationToken).ConfigureAwait(false); 114 | if (!string.IsNullOrEmpty(newImdbId)) 115 | { 116 | c.Imdb = newImdbId; 117 | } 118 | item.SetProviderId(MetadataProvider.Imdb, c.Imdb); 119 | // 通过imdb获取TMDB id 120 | var findResult = await this._tmdbApi.FindByExternalIdAsync(c.Imdb, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); 121 | if (findResult?.PersonResults != null && findResult.PersonResults.Count > 0) 122 | { 123 | var foundTmdbId = findResult.PersonResults.First().Id.ToString(); 124 | this.Log($"GetPersonMetadata of found tmdb [id]: {foundTmdbId}"); 125 | item.SetProviderId(MetadataProvider.Tmdb, $"{foundTmdbId}"); 126 | } 127 | } 128 | 129 | result.QueriedById = true; 130 | result.HasMetadata = true; 131 | result.Item = item; 132 | 133 | return result; 134 | } 135 | } 136 | 137 | // jellyfin强制最后一定使用默认的TheMovieDb插件获取一次,这里不太必要(除了使用自己的域名) 138 | var personTmdbId = info.GetProviderId(MetadataProvider.Tmdb); 139 | this.Log($"GetPersonMetadata of [personTmdbId]: {personTmdbId}"); 140 | if (!string.IsNullOrEmpty(personTmdbId)) 141 | { 142 | return await this.GetMetadataByTmdb(personTmdbId.ToInt(), info, cancellationToken).ConfigureAwait(false); 143 | } 144 | 145 | return result; 146 | } 147 | 148 | public async Task> GetMetadataByTmdb(int personTmdbId, PersonLookupInfo info, CancellationToken cancellationToken) 149 | { 150 | var result = new MetadataResult(); 151 | var person = await this._tmdbApi.GetPersonAsync(personTmdbId, cancellationToken).ConfigureAwait(false); 152 | if (person != null) 153 | { 154 | var item = new Person 155 | { 156 | // Name = info.Name.Trim(), // 名称需保持和info.Name一致,不然会导致关联不到影片,自动被删除 157 | HomePageUrl = person.Homepage, 158 | Overview = person.Biography, 159 | PremiereDate = person.Birthday?.ToUniversalTime(), 160 | EndDate = person.Deathday?.ToUniversalTime() 161 | }; 162 | 163 | if (!string.IsNullOrWhiteSpace(person.PlaceOfBirth)) 164 | { 165 | item.ProductionLocations = new[] { person.PlaceOfBirth }; 166 | } 167 | 168 | item.SetProviderId(MetadataProvider.Tmdb, person.Id.ToString(CultureInfo.InvariantCulture)); 169 | if (!string.IsNullOrEmpty(person.ImdbId)) 170 | { 171 | item.SetProviderId(MetadataProvider.Imdb, person.ImdbId); 172 | } 173 | 174 | result.HasMetadata = true; 175 | result.Item = item; 176 | 177 | return result; 178 | } 179 | 180 | return result; 181 | } 182 | 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/SeasonImageProvider.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Model; 3 | using MediaBrowser.Controller.Entities; 4 | using MediaBrowser.Controller.Entities.TV; 5 | using MediaBrowser.Controller.Library; 6 | using MediaBrowser.Controller.Providers; 7 | using MediaBrowser.Model.Entities; 8 | using MediaBrowser.Model.Providers; 9 | using Microsoft.AspNetCore.Http; 10 | using Microsoft.Extensions.Logging; 11 | using System; 12 | using System.Collections.Generic; 13 | using System.Globalization; 14 | using System.Linq; 15 | using System.Net.Http; 16 | using System.Threading; 17 | using System.Threading.Tasks; 18 | 19 | namespace Jellyfin.Plugin.MetaShark.Providers 20 | { 21 | public class SeasonImageProvider : BaseProvider, IRemoteImageProvider 22 | { 23 | public SeasonImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi) 24 | : base(httpClientFactory, loggerFactory.CreateLogger(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi) 25 | { 26 | } 27 | 28 | /// 29 | public string Name => Plugin.PluginName; 30 | 31 | /// 32 | public bool Supports(BaseItem item) => item is Season; 33 | 34 | /// 35 | public IEnumerable GetSupportedImages(BaseItem item) 36 | { 37 | yield return ImageType.Primary; 38 | } 39 | 40 | /// 41 | public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) 42 | { 43 | this.Log($"GetSeasonImages for item: {item.Name} number: {item.IndexNumber}"); 44 | var season = (Season)item; 45 | var series = season.Series; 46 | var metaSource = series.GetMetaSource(Plugin.ProviderId); 47 | 48 | // get image from douban 49 | var sid = item.GetProviderId(DoubanProviderId); 50 | if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid)) 51 | { 52 | var primary = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false); 53 | if (primary == null) 54 | { 55 | return Enumerable.Empty(); 56 | } 57 | 58 | var res = new List { 59 | new RemoteImageInfo 60 | { 61 | ProviderName = primary.Name, 62 | Url = this.GetDoubanPoster(primary), 63 | Type = ImageType.Primary, 64 | Language = "zh", 65 | }, 66 | }; 67 | return res; 68 | } 69 | 70 | 71 | // get image form TMDB 72 | var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); 73 | if (seriesTmdbId <= 0 || season?.IndexNumber == null) 74 | { 75 | return Enumerable.Empty(); 76 | } 77 | 78 | var language = item.GetPreferredMetadataLanguage(); 79 | var seasonResult = await this._tmdbApi 80 | .GetSeasonAsync(seriesTmdbId, season.IndexNumber.Value, null, null, cancellationToken) 81 | .ConfigureAwait(false); 82 | var posters = seasonResult?.Images?.Posters; 83 | if (posters == null) 84 | { 85 | return Enumerable.Empty(); 86 | } 87 | 88 | var remoteImages = new RemoteImageInfo[posters.Count]; 89 | for (var i = 0; i < posters.Count; i++) 90 | { 91 | var image = posters[i]; 92 | remoteImages[i] = new RemoteImageInfo 93 | { 94 | Url = this._tmdbApi.GetPosterUrl(image.FilePath), 95 | CommunityRating = image.VoteAverage, 96 | VoteCount = image.VoteCount, 97 | Width = image.Width, 98 | Height = image.Height, 99 | Language = AdjustImageLanguage(image.Iso_639_1, language), 100 | ProviderName = Name, 101 | Type = ImageType.Primary, 102 | }; 103 | } 104 | 105 | return remoteImages.OrderByLanguageDescending(language); 106 | } 107 | 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/Providers/SeriesImageProvider.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using Jellyfin.Plugin.MetaShark.Core; 3 | using Jellyfin.Plugin.MetaShark.Model; 4 | using MediaBrowser.Controller.Entities; 5 | using MediaBrowser.Controller.Entities.TV; 6 | using MediaBrowser.Controller.Library; 7 | using MediaBrowser.Controller.Providers; 8 | using MediaBrowser.Model.Dto; 9 | using MediaBrowser.Model.Entities; 10 | using MediaBrowser.Model.Providers; 11 | using Microsoft.AspNetCore.Http; 12 | using Microsoft.Extensions.Logging; 13 | using System.Collections.Generic; 14 | using System.Linq; 15 | using System.Net.Http; 16 | using System.Threading; 17 | using System.Threading.Tasks; 18 | 19 | namespace Jellyfin.Plugin.MetaShark.Providers 20 | { 21 | public class SeriesImageProvider : BaseProvider, IRemoteImageProvider 22 | { 23 | public SeriesImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, ILibraryManager libraryManager, IHttpContextAccessor httpContextAccessor, DoubanApi doubanApi, TmdbApi tmdbApi, OmdbApi omdbApi, ImdbApi imdbApi) 24 | : base(httpClientFactory, loggerFactory.CreateLogger(), libraryManager, httpContextAccessor, doubanApi, tmdbApi, omdbApi, imdbApi) 25 | { 26 | } 27 | 28 | /// 29 | public string Name => Plugin.PluginName; 30 | 31 | /// 32 | public bool Supports(BaseItem item) => item is Series; 33 | 34 | /// 35 | public IEnumerable GetSupportedImages(BaseItem item) => new List 36 | { 37 | ImageType.Primary, 38 | ImageType.Backdrop, 39 | ImageType.Logo, 40 | }; 41 | 42 | /// 43 | public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) 44 | { 45 | var sid = item.GetProviderId(DoubanProviderId); 46 | var metaSource = item.GetMetaSource(Plugin.ProviderId); 47 | this.Log($"GetImages for item: {item.Name} [metaSource]: {metaSource}"); 48 | if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid)) 49 | { 50 | var primary = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false); 51 | if (primary == null || string.IsNullOrEmpty(primary.Img)) 52 | { 53 | return Enumerable.Empty(); 54 | } 55 | var res = new List { 56 | new RemoteImageInfo 57 | { 58 | ProviderName = this.Name, 59 | Url = this.GetDoubanPoster(primary), 60 | Type = ImageType.Primary, 61 | Language = "zh", 62 | }, 63 | }; 64 | 65 | var backdropImgs = await this.GetBackdrop(item, primary.PrimaryLanguageCode, cancellationToken).ConfigureAwait(false); 66 | var logoImgs = await this.GetLogos(item, primary.PrimaryLanguageCode, cancellationToken).ConfigureAwait(false); 67 | res.AddRange(backdropImgs); 68 | res.AddRange(logoImgs); 69 | return res; 70 | } 71 | 72 | var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); 73 | if (metaSource == MetaSource.Tmdb && !string.IsNullOrEmpty(tmdbId)) 74 | { 75 | var language = item.GetPreferredMetadataLanguage(); 76 | // 设定language会导致图片被过滤,这里设为null,保持取全部语言图片 77 | var movie = await this._tmdbApi 78 | .GetSeriesAsync(tmdbId.ToInt(), null, null, cancellationToken) 79 | .ConfigureAwait(false); 80 | 81 | if (movie?.Images == null) 82 | { 83 | return Enumerable.Empty(); 84 | } 85 | 86 | var remoteImages = new List(); 87 | 88 | remoteImages.AddRange(movie.Images.Posters.Select(x => new RemoteImageInfo { 89 | ProviderName = this.Name, 90 | Url = this._tmdbApi.GetPosterUrl(x.FilePath), 91 | Type = ImageType.Primary, 92 | CommunityRating = x.VoteAverage, 93 | VoteCount = x.VoteCount, 94 | Width = x.Width, 95 | Height = x.Height, 96 | Language = this.AdjustImageLanguage(x.Iso_639_1, language), 97 | RatingType = RatingType.Score, 98 | })); 99 | 100 | remoteImages.AddRange(movie.Images.Backdrops.Select(x => new RemoteImageInfo { 101 | ProviderName = this.Name, 102 | Url = this._tmdbApi.GetBackdropUrl(x.FilePath), 103 | Type = ImageType.Backdrop, 104 | CommunityRating = x.VoteAverage, 105 | VoteCount = x.VoteCount, 106 | Width = x.Width, 107 | Height = x.Height, 108 | Language = this.AdjustImageLanguage(x.Iso_639_1, language), 109 | RatingType = RatingType.Score, 110 | })); 111 | 112 | remoteImages.AddRange(movie.Images.Logos.Select(x => new RemoteImageInfo { 113 | ProviderName = this.Name, 114 | Url = this._tmdbApi.GetLogoUrl(x.FilePath), 115 | Type = ImageType.Logo, 116 | CommunityRating = x.VoteAverage, 117 | VoteCount = x.VoteCount, 118 | Width = x.Width, 119 | Height = x.Height, 120 | Language = this.AdjustImageLanguage(x.Iso_639_1, language), 121 | RatingType = RatingType.Score, 122 | })); 123 | 124 | return remoteImages.OrderByLanguageDescending(language); 125 | } 126 | 127 | this.Log($"Got images failed because the images of \"{item.Name}\" is empty!"); 128 | return new List(); 129 | } 130 | 131 | /// 132 | /// Query for a background photo 133 | /// 134 | /// Instance of the interface. 135 | private async Task> GetBackdrop(BaseItem item, string alternativeImageLanguage, CancellationToken cancellationToken) 136 | { 137 | var sid = item.GetProviderId(DoubanProviderId); 138 | var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); 139 | var list = new List(); 140 | 141 | // 从豆瓣获取背景图 142 | if (!string.IsNullOrEmpty(sid)) 143 | { 144 | var photo = await this._doubanApi.GetWallpaperBySidAsync(sid, cancellationToken); 145 | if (photo != null && photo.Count > 0) 146 | { 147 | this.Log("GetBackdrop from douban sid: {0}", sid); 148 | list = photo.Where(x => x.Width >= 1280 && x.Width <= 4096 && x.Width > x.Height * 1.3).Select(x => 149 | { 150 | if (config.EnableDoubanBackdropRaw) 151 | { 152 | return new RemoteImageInfo 153 | { 154 | ProviderName = Name, 155 | Url = this.GetProxyImageUrl(x.Raw), 156 | Height = x.Height, 157 | Width = x.Width, 158 | Type = ImageType.Backdrop, 159 | Language = "zh", 160 | }; 161 | } 162 | else 163 | { 164 | return new RemoteImageInfo 165 | { 166 | ProviderName = Name, 167 | Url = this.GetProxyImageUrl(x.Large), 168 | Type = ImageType.Backdrop, 169 | Language = "zh", 170 | }; 171 | } 172 | }).ToList(); 173 | } 174 | } 175 | 176 | // 添加 TheMovieDb 背景图为备选 177 | if (config.EnableTmdbBackdrop && !string.IsNullOrEmpty(tmdbId)) 178 | { 179 | var language = item.GetPreferredMetadataLanguage(); 180 | var movie = await _tmdbApi 181 | .GetSeriesAsync(tmdbId.ToInt(), language, language, cancellationToken) 182 | .ConfigureAwait(false); 183 | 184 | if (movie != null && !string.IsNullOrEmpty(movie.BackdropPath)) 185 | { 186 | this.Log("GetBackdrop from tmdb id: {0} lang: {1}", tmdbId, language); 187 | list.Add(new RemoteImageInfo 188 | { 189 | ProviderName = this.Name, 190 | Url = this._tmdbApi.GetBackdropUrl(movie.BackdropPath), 191 | Type = ImageType.Backdrop, 192 | Language = language, 193 | }); 194 | } 195 | } 196 | 197 | return list; 198 | } 199 | 200 | private async Task> GetLogos(BaseItem item, string alternativeImageLanguage, CancellationToken cancellationToken) 201 | { 202 | var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); 203 | var language = item.GetPreferredMetadataLanguage(); 204 | var list = new List(); 205 | if (this.config.EnableTmdbLogo && !string.IsNullOrEmpty(tmdbId)) 206 | { 207 | this.Log("GetLogos from tmdb id: {0}", tmdbId); 208 | var movie = await this._tmdbApi 209 | .GetSeriesAsync(tmdbId.ToInt(), null, null, cancellationToken) 210 | .ConfigureAwait(false); 211 | 212 | if (movie != null && movie.Images != null) 213 | { 214 | list.AddRange(movie.Images.Logos.Select(x => new RemoteImageInfo { 215 | ProviderName = this.Name, 216 | Url = this._tmdbApi.GetLogoUrl(x.FilePath), 217 | Type = ImageType.Logo, 218 | CommunityRating = x.VoteAverage, 219 | VoteCount = x.VoteCount, 220 | Width = x.Width, 221 | Height = x.Height, 222 | Language = this.AdjustImageLanguage(x.Iso_639_1, language), 223 | RatingType = RatingType.Score, 224 | })); 225 | } 226 | } 227 | 228 | // TODO:jellyfin 内部判断取哪个图片时,还会默认使用 OrderByLanguageDescending 排序一次,这里排序没用 229 | // 默认图片优先级是:默认语言 > 无语言 > en > 其他语言 230 | return this.AdjustImageLanguagePriority(list, language, alternativeImageLanguage); 231 | } 232 | 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/ScheduledTasks/AutoCreateCollectionTask.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using MediaBrowser.Controller.Library; 6 | using MediaBrowser.Model.Tasks; 7 | using Microsoft.Extensions.Logging; 8 | using MediaBrowser.Controller.Collections; 9 | 10 | namespace Jellyfin.Plugin.MetaShark.ScheduledTasks 11 | { 12 | public class AutoCreateCollectionTask : IScheduledTask 13 | { 14 | private readonly BoxSetManager _boxSetManager; 15 | private readonly ILogger _logger; 16 | 17 | public string Key => $"{Plugin.PluginName}AutoCreateCollection"; 18 | 19 | public string Name => "扫描自动创建合集"; 20 | 21 | public string Description => $"扫描媒体库创建合集,需要先在配置中开启获取电影系列信息"; 22 | 23 | public string Category => Plugin.PluginName; 24 | 25 | /// 26 | /// Initializes a new instance of the class. 27 | /// 28 | /// Instance of the interface. 29 | /// Instance of the interface. 30 | public AutoCreateCollectionTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, ICollectionManager collectionManager) 31 | { 32 | _logger = loggerFactory.CreateLogger(); 33 | _boxSetManager = new BoxSetManager(libraryManager, collectionManager, loggerFactory); 34 | } 35 | 36 | public IEnumerable GetDefaultTriggers() 37 | { 38 | yield return new TaskTriggerInfo 39 | { 40 | Type = TaskTriggerInfo.TriggerDaily, 41 | TimeOfDayTicks = TimeSpan.FromHours(0).Ticks 42 | }; 43 | } 44 | 45 | public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) 46 | { 47 | _logger.LogInformation("开始扫描媒体库自动创建合集..."); 48 | await _boxSetManager.ScanLibrary(progress).ConfigureAwait(false); 49 | _logger.LogInformation("扫描媒体库自动创建合集执行完成"); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.MetaShark/ServiceRegistrator.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Plugin.MetaShark.Api; 2 | using MediaBrowser.Controller; 3 | using MediaBrowser.Controller.Plugins; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace Jellyfin.Plugin.MetaShark 8 | { 9 | /// 10 | public class ServiceRegistrator : IPluginServiceRegistrator 11 | { 12 | /// 13 | public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) 14 | { 15 | serviceCollection.AddHostedService(); 16 | serviceCollection.AddSingleton((ctx) => 17 | { 18 | return new DoubanApi(ctx.GetRequiredService()); 19 | }); 20 | serviceCollection.AddSingleton((ctx) => 21 | { 22 | return new TmdbApi(ctx.GetRequiredService()); 23 | }); 24 | serviceCollection.AddSingleton((ctx) => 25 | { 26 | return new OmdbApi(ctx.GetRequiredService()); 27 | }); 28 | serviceCollection.AddSingleton((ctx) => 29 | { 30 | return new ImdbApi(ctx.GetRequiredService()); 31 | }); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jellyfin-plugin-metashark 2 | 3 | [![release](https://img.shields.io/github/v/release/cxfksword/jellyfin-plugin-metashark)](https://github.com/cxfksword/jellyfin-plugin-metashark/releases) 4 | [![platform](https://img.shields.io/badge/jellyfin-10.9.x|10.10.x-lightgrey?logo=jellyfin)](https://github.com/cxfksword/jellyfin-plugin-metashark/releases) 5 | [![license](https://img.shields.io/github/license/cxfksword/jellyfin-plugin-metashark)](https://github.com/cxfksword/jellyfin-plugin-metashark/main/LICENSE) 6 | 7 | jellyfin电影元数据插件,影片信息只要从豆瓣获取,并由TheMovieDb补全缺失的剧集数据。 8 | 9 | 功能: 10 | * 支持从豆瓣和TMDB获取元数据 11 | * 兼容anime动画命名格式 12 | 13 | ![logo](doc/logo.png) 14 | 15 | ## 安装插件 16 | 17 | 添加插件存储库: 18 | 19 | 国内加速:https://ghfast.top/https://github.com/cxfksword/jellyfin-plugin-metashark/releases/download/manifest/manifest_cn.json 20 | 21 | 国外访问:https://github.com/cxfksword/jellyfin-plugin-metashark/releases/download/manifest/manifest.json 22 | 23 | > 如果都无法访问,可以直接从 [Release](https://github.com/cxfksword/jellyfin-plugin-metashark/releases) 页面下载,并解压到 jellyfin 插件目录中使用 24 | 25 | ## 如何使用 26 | 27 | 1. 安装后,先进入`控制台 -> 插件`,查看下MetaShark插件是否是**Active**状态 28 | 2. 进入`控制台 -> 媒体库`,点击任一媒体库进入配置页,在元数据下载器选项中勾选**MetaShark**,并把**MetaShark**移动到第一位 29 | 30 | 31 | 32 | 3. 识别时默认不返回TheMovieDb结果,有需要可以到插件配置中打开 33 | 4. 假如网络原因访问TheMovieDb比较慢,可以到插件配置中关闭从TheMovieDb获取数据(关闭后不会再获取剧集信息) 34 | 35 | > 🚨假如需要刮削大量电影,请到插件配置中打开防封禁功能,避免频繁请求豆瓣导致被封IP(封IP需要等6小时左右才能恢复访问) 36 | 37 | > :fire:遇到图片显示不出来时,请到插件配置中配置jellyfin访问域名 38 | 39 | ## How to build 40 | 41 | 1. Clone or download this repository 42 | 43 | 2. Ensure you have .NET Core SDK 8.0 setup and installed 44 | 45 | 3. Build plugin with following command. 46 | 47 | ```sh 48 | dotnet restore 49 | dotnet publish --configuration=Release Jellyfin.Plugin.MetaShark/Jellyfin.Plugin.MetaShark.csproj 50 | ``` 51 | 52 | 53 | ## How to test 54 | 55 | 1. Build the plugin 56 | 57 | 2. Create a folder, like `metashark` and copy `./Jellyfin.Plugin.MetaShark/bin/Release/net8.0/Jellyfin.Plugin.MetaShark.dll` into it 58 | 59 | 3. Move folder `metashark` to jellyfin `data/plugins` folder 60 | 61 | 62 | ## FAQ 63 | 64 | 1. Plugin run in error: `System.BadImageFormatException: Bad IL format.` 65 | 66 | Remove all hidden file and `meta.json` in `metashark` plugin folder 67 | 68 | 69 | ## Thanks 70 | 71 | [AnitomySharp](https://github.com/chu-shen/AnitomySharp) 72 | 73 | ## 免责声明 74 | 75 | 本项目代码仅用于学习交流编程技术,下载后请勿用于商业用途。 76 | 77 | 如果本项目存在侵犯您的合法权益的情况,请及时与开发者联系,开发者将会及时删除有关内容。 -------------------------------------------------------------------------------- /doc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cxfksword/jellyfin-plugin-metashark/023b1b647f28cb678cdfce945a0ce4383fea751c/doc/logo.png -------------------------------------------------------------------------------- /scripts/generate_manifest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import hashlib 3 | import json 4 | import sys 5 | import re 6 | import os 7 | import subprocess 8 | from datetime import datetime 9 | from urllib.request import urlopen 10 | from urllib.error import HTTPError 11 | 12 | 13 | def generate_manifest(): 14 | return [{ 15 | "guid": "9a19103f-16f7-4668-be54-9a1e7a4f7556", 16 | "name": "MetaShark", 17 | "description": "jellyfin电影元数据插件,影片信息只要从豆瓣获取,并由TMDB补充缺失的剧集数据。", 18 | "overview": "jellyfin电影元数据插件", 19 | "owner": "cxfksword", 20 | "category": "Metadata", 21 | "imageUrl": "https://github.com/cxfksword/jellyfin-plugin-metashark/raw/main/doc/logo.png", 22 | "versions": [] 23 | }] 24 | 25 | def generate_version(filepath, version, changelog): 26 | return { 27 | 'version': f"{version}.0", 28 | 'changelog': changelog, 29 | 'targetAbi': '10.9.0.0', 30 | 'sourceUrl': f'https://github.com/cxfksword/jellyfin-plugin-metashark/releases/download/v{version}/metashark_{version}.0.zip', 31 | 'checksum': md5sum(filepath), 32 | 'timestamp': datetime.now().strftime('%Y-%m-%dT%H:%M:%S') 33 | } 34 | 35 | def md5sum(filename): 36 | with open(filename, 'rb') as f: 37 | return hashlib.md5(f.read()).hexdigest() 38 | 39 | 40 | def main(): 41 | filename = sys.argv[1] 42 | tag = sys.argv[2] 43 | version = tag.lstrip('v') 44 | filepath = os.path.join(os.getcwd(), filename) 45 | result = subprocess.run(['git', 'tag','-l','--format=%(contents)', tag, '-l'], stdout=subprocess.PIPE) 46 | changelog = result.stdout.decode('utf-8').strip() 47 | 48 | # 解析旧 manifest 49 | try: 50 | with urlopen('https://github.com/cxfksword/jellyfin-plugin-metashark/releases/download/manifest/manifest.json') as f: 51 | manifest = json.load(f) 52 | except HTTPError as err: 53 | if err.code == 404: 54 | manifest = generate_manifest() 55 | else: 56 | raise 57 | 58 | # 追加新版本/覆盖旧版本 59 | manifest[0]['versions'] = list(filter(lambda x: x['version'] != f"{version}.0", manifest[0]['versions'])) 60 | manifest[0]['versions'].insert(0, generate_version(filepath, version, changelog)) 61 | 62 | with open('manifest.json', 'w') as f: 63 | json.dump(manifest, f, indent=2) 64 | 65 | # # 国内加速 66 | cn_domain = 'https://ghfast.top/' 67 | if 'CN_DOMAIN' in os.environ and os.environ["CN_DOMAIN"]: 68 | cn_domain = os.environ["CN_DOMAIN"] 69 | cn_domain = cn_domain.rstrip('/') 70 | with open('manifest_cn.json', 'w') as f: 71 | manifest_cn = json.dumps(manifest, indent=2) 72 | manifest_cn = re.sub('https://github.com', f'{cn_domain}/https://github.com', manifest_cn) 73 | f.write(manifest_cn) 74 | 75 | 76 | if __name__ == '__main__': 77 | main() --------------------------------------------------------------------------------